Compare commits
6
Commits
main
...
main_秤数据管理封装
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5a9522fa7 | ||
|
|
967d06ce1e | ||
|
|
d5c6ec92d4 | ||
|
|
7b28eb03d6 | ||
|
|
4d545ceb46 | ||
|
|
0eede1059c |
@@ -29,15 +29,10 @@ 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.UiState
|
import com.shuwei.intelligent.shelves.net.UiState
|
||||||
|
import com.shuwei.intelligent.shelves.serial.ProtocolConstants
|
||||||
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.C_TEMP_CMD
|
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.C_TEMP_CMD
|
||||||
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.FOOTER
|
|
||||||
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.HEADER
|
|
||||||
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.LEFT_SHELF_OPEN_CMD
|
|
||||||
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.RIGHT_SHELF_OPEN_CMD
|
|
||||||
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.START_TEMP_CTRL_CMD
|
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.START_TEMP_CTRL_CMD
|
||||||
import com.shuwei.intelligent.shelves.serial.SerialFrameParser
|
import com.shuwei.intelligent.shelves.serial.ScaleManager
|
||||||
import com.shuwei.intelligent.shelves.serial.SerialPortManager
|
|
||||||
import com.shuwei.intelligent.shelves.serial.SerialProtocolHandler
|
|
||||||
import com.shuwei.intelligent.shelves.utils.GridLayoutTool
|
import com.shuwei.intelligent.shelves.utils.GridLayoutTool
|
||||||
import com.shuwei.intelligent.shelves.utils.IntervalExecutor
|
import com.shuwei.intelligent.shelves.utils.IntervalExecutor
|
||||||
import com.shuwei.intelligent.shelves.utils.ext.copyTextToClipboard
|
import com.shuwei.intelligent.shelves.utils.ext.copyTextToClipboard
|
||||||
@@ -80,41 +75,36 @@ class HomeActivity : BaseActivity() {
|
|||||||
private val viewModel: NetViewModel by viewModels()
|
private val viewModel: NetViewModel by viewModels()
|
||||||
|
|
||||||
// 串口协议解析器,通过回调通知本 Activity
|
// 串口协议解析器,通过回调通知本 Activity
|
||||||
private val serialProtocolHandler =
|
private fun initScaleManager() {
|
||||||
SerialProtocolHandler(object : SerialProtocolHandler.Callback {
|
lifecycleScope.launch {
|
||||||
override fun onSendCmd(cmd: String) = sendCmd(cmd)
|
val ok = ScaleManager
|
||||||
|
.init(activateCode = ProtocolConstants.ACTIVE_MAP[App.deviceId] ?: "")
|
||||||
override fun onWeightUpdated(shelfNo: Int, weight: Int) {
|
.onHeartbeat { _, data ->
|
||||||
if (shelfNo in 1..list.size) {
|
data.weights.forEach { weight ->
|
||||||
list.firstOrNull { it.deviceNo == shelfNo }?.let { model ->
|
val shelfNo = weight.index
|
||||||
model.weight = weight.toDouble()
|
if (shelfNo in 1..list.size) {
|
||||||
val pos = list.indexOf(model)
|
list.firstOrNull { it.deviceNo == shelfNo }?.let { model ->
|
||||||
shelfAdapter.notifyItemChanged(pos)
|
model.weight = weight.grams.toDouble()
|
||||||
log("getWeightInfo: deviceNo=${model.deviceNo},realWeight=$weight")
|
val pos = list.indexOf(model)
|
||||||
EventBus.getDefault().post(SendWeightEvent(model.deviceNo, weight))
|
shelfAdapter.notifyItemChanged(pos)
|
||||||
|
log("getWeightInfo: deviceNo=${model.deviceNo},realWeight=${weight.grams}")
|
||||||
|
EventBus.getDefault().post(SendWeightEvent(model.deviceNo, weight.grams))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
.onTempReport { _ ->
|
||||||
|
updateLeftStatus(deviceName)
|
||||||
override fun onTempReport(logMsg: String) {
|
}
|
||||||
updateLeftStatus(deviceName)
|
.onLog { message -> log(message) }
|
||||||
log(logMsg)
|
.start { data ->
|
||||||
// if (tipDialog == null) {
|
lastDataReceivedTime = System.currentTimeMillis()
|
||||||
// tipDialog = CommonDialog(this@HomeActivity).apply {
|
noDataWarningDialog?.takeIf { it.isShowing }?.dismiss()
|
||||||
// dialogTitle = "温馨提示"
|
log("receiveSerialPortData: $data")
|
||||||
// confirmBlock = { logMsg.copyTextToClipboard(this@HomeActivity) }
|
}
|
||||||
// }
|
log("onCreate: openState=$ok")
|
||||||
// }
|
}
|
||||||
// if (tipDialog?.isShowing == false) {
|
}
|
||||||
// tipDialog?.dialogContent = logMsg
|
|
||||||
// tipDialog?.show()
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onLog(message: String) = log(message)
|
|
||||||
})
|
|
||||||
|
|
||||||
private val serialFrameParser = SerialFrameParser()
|
|
||||||
|
|
||||||
@SuppressLint("NotifyDataSetChanged")
|
@SuppressLint("NotifyDataSetChanged")
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
@@ -129,24 +119,8 @@ class HomeActivity : BaseActivity() {
|
|||||||
updateLeftStatus("")
|
updateLeftStatus("")
|
||||||
initRecyclerView()
|
initRecyclerView()
|
||||||
|
|
||||||
// 串口初始化,独立协程,不阻塞网络状态监听
|
// 初始化 ScaleManager:注册回调、打开串口、启动接收
|
||||||
lifecycleScope.launch {
|
initScaleManager()
|
||||||
val openState = SerialPortManager.open()
|
|
||||||
log("onCreate: openState=$openState")
|
|
||||||
if (openState) {
|
|
||||||
SerialPortManager.startReceive { data ->
|
|
||||||
// 切换到主线程协程处理串口数据
|
|
||||||
lifecycleScope.launch {
|
|
||||||
runCatching { receiveSerialPortData(data) }
|
|
||||||
.onFailure {
|
|
||||||
it.printStackTrace()
|
|
||||||
log("串口数据处理异常: ${it.message}")
|
|
||||||
toast("串口数据处理异常,请检查设备连接")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 网络状态监听,独立协程,与串口初始化并行执行
|
// 网络状态监听,独立协程,与串口初始化并行执行
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
@@ -257,15 +231,6 @@ class HomeActivity : BaseActivity() {
|
|||||||
loadEmptyView()
|
loadEmptyView()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun receiveSerialPortData(srcData: String) {
|
|
||||||
log("receiveSerialPortData: $srcData")
|
|
||||||
// 重置无数据计时,并关闭已显示的超时提示弹窗
|
|
||||||
lastDataReceivedTime = System.currentTimeMillis()
|
|
||||||
noDataWarningDialog?.takeIf { it.isShowing }?.dismiss()
|
|
||||||
val frame = serialFrameParser.parse(srcData) ?: return
|
|
||||||
serialProtocolHandler.handle(frame)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var tipDialog: CommonDialog? = null
|
private var tipDialog: CommonDialog? = null
|
||||||
|
|
||||||
/** 最后一次成功收到串口数据的时间戳(毫秒) */
|
/** 最后一次成功收到串口数据的时间戳(毫秒) */
|
||||||
@@ -317,7 +282,7 @@ class HomeActivity : BaseActivity() {
|
|||||||
noDataWarningDialog?.dismiss()
|
noDataWarningDialog?.dismiss()
|
||||||
noDataWarningDialog = null
|
noDataWarningDialog = null
|
||||||
|
|
||||||
runBlocking { SerialPortManager.close() }
|
runBlocking { ScaleManager.release() }
|
||||||
saveTaskJob?.cancel()
|
saveTaskJob?.cancel()
|
||||||
overdueTaskJob?.cancel()
|
overdueTaskJob?.cancel()
|
||||||
EventBus.getDefault().unregister(this)
|
EventBus.getDefault().unregister(this)
|
||||||
@@ -340,10 +305,10 @@ class HomeActivity : BaseActivity() {
|
|||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
if (isNeedOpenLock()) {
|
if (isNeedOpenLock()) {
|
||||||
val openCmd = if (list[position].deviceNo in 1..5)
|
val openCmd = if (list[position].deviceNo in 1..5)
|
||||||
LEFT_SHELF_OPEN_CMD
|
ScaleManager.buildOpenLock1Cmd()
|
||||||
else
|
else
|
||||||
RIGHT_SHELF_OPEN_CMD
|
ScaleManager.buildOpenLock2Cmd()
|
||||||
SerialPortManager.send(openCmd)
|
ScaleManager.sendCmd(openCmd)
|
||||||
}
|
}
|
||||||
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, list[position])
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import com.chad.library.adapter4.util.setOnDebouncedItemClick
|
|||||||
import com.scwang.smart.refresh.layout.constant.RefreshState
|
import com.scwang.smart.refresh.layout.constant.RefreshState
|
||||||
import com.shuwei.intelligent.shelves.App
|
import com.shuwei.intelligent.shelves.App
|
||||||
import com.shuwei.intelligent.shelves.R
|
import com.shuwei.intelligent.shelves.R
|
||||||
import com.shuwei.intelligent.shelves.serial.ProtocolConstants
|
import com.shuwei.intelligent.shelves.serial.ScaleManager
|
||||||
import com.shuwei.intelligent.shelves.adapter.SearchAdapter
|
import com.shuwei.intelligent.shelves.adapter.SearchAdapter
|
||||||
import com.shuwei.intelligent.shelves.base.BaseActivity
|
import com.shuwei.intelligent.shelves.base.BaseActivity
|
||||||
import com.shuwei.intelligent.shelves.databinding.ActivityShelfBinding
|
import com.shuwei.intelligent.shelves.databinding.ActivityShelfBinding
|
||||||
@@ -146,7 +146,7 @@ class ShelfActivity : BaseActivity() {
|
|||||||
//binding.tvFoodWeight.text = "0克"
|
//binding.tvFoodWeight.text = "0克"
|
||||||
EventBus.getDefault().post(ClearShelfEvent(shelfModel!!.deviceNo, tempWeight))
|
EventBus.getDefault().post(ClearShelfEvent(shelfModel!!.deviceNo, tempWeight))
|
||||||
}
|
}
|
||||||
sendCmd(ProtocolConstants.DEVICE_INFO_CMD)
|
sendCmd(ScaleManager.buildGetStatusCmd())
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
viewModel.getGoodsListUiState.collect { state ->
|
viewModel.getGoodsListUiState.collect { state ->
|
||||||
@@ -359,9 +359,7 @@ class ShelfActivity : BaseActivity() {
|
|||||||
private fun clearZero() {
|
private fun clearZero() {
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
val shelfNo = shelfModel?.deviceNo ?: 0
|
val shelfNo = shelfModel?.deviceNo ?: 0
|
||||||
val hex = shelfNo.toString(16).padStart(2, '0').uppercase()
|
val zeroClearingCmd = ScaleManager.buildScaleZeroCmd(shelfNo)
|
||||||
Log.d(TAG, "onCreate: hex=$hex")
|
|
||||||
val zeroClearingCmd = "${ProtocolConstants.HEADER}0401${hex}${ProtocolConstants.FOOTER}"
|
|
||||||
log("编号${shelfNo}货架执行清零指令:${zeroClearingCmd}")
|
log("编号${shelfNo}货架执行清零指令:${zeroClearingCmd}")
|
||||||
sendCmd(zeroClearingCmd)
|
sendCmd(zeroClearingCmd)
|
||||||
// sendCmd(
|
// sendCmd(
|
||||||
@@ -371,7 +369,7 @@ class ShelfActivity : BaseActivity() {
|
|||||||
realWeight = 0
|
realWeight = 0
|
||||||
// binding.tvFoodWeight.text = "${realWeight}克"
|
// binding.tvFoodWeight.text = "${realWeight}克"
|
||||||
|
|
||||||
sendCmd(ProtocolConstants.DEVICE_INFO_CMD)
|
sendCmd(ScaleManager.buildGetStatusCmd())
|
||||||
startTime = System.currentTimeMillis()
|
startTime = System.currentTimeMillis()
|
||||||
Loading.show(this@ShelfActivity)
|
Loading.show(this@ShelfActivity)
|
||||||
window.decorView.postDelayed({ Loading.dismiss() }, 10000)
|
window.decorView.postDelayed({ Loading.dismiss() }, 10000)
|
||||||
|
|||||||
@@ -12,14 +12,13 @@ import androidx.activity.result.ActivityResultLauncher
|
|||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import com.shuwei.intelligent.shelves.activity.LogActivity
|
import com.shuwei.intelligent.shelves.activity.LogActivity
|
||||||
import com.shuwei.intelligent.shelves.R
|
import com.shuwei.intelligent.shelves.R
|
||||||
import com.shuwei.intelligent.shelves.databinding.ActivityBaseBinding
|
import com.shuwei.intelligent.shelves.databinding.ActivityBaseBinding
|
||||||
import com.shuwei.intelligent.shelves.databinding.PopupRightStatusMenuBinding
|
import com.shuwei.intelligent.shelves.databinding.PopupRightStatusMenuBinding
|
||||||
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.LIGHT_CLOSE
|
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.LIGHT_CLOSE
|
||||||
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.LIGHT_OPEN
|
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.LIGHT_OPEN
|
||||||
import com.shuwei.intelligent.shelves.serial.SerialPortManager
|
import com.shuwei.intelligent.shelves.serial.ScaleManager
|
||||||
import com.shuwei.intelligent.shelves.utils.FileLogger
|
import com.shuwei.intelligent.shelves.utils.FileLogger
|
||||||
import com.shuwei.intelligent.shelves.utils.ext.clickWithDebounce
|
import com.shuwei.intelligent.shelves.utils.ext.clickWithDebounce
|
||||||
import com.shuwei.intelligent.shelves.utils.ext.dp
|
import com.shuwei.intelligent.shelves.utils.ext.dp
|
||||||
@@ -214,11 +213,9 @@ open class BaseActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fun sendCmd(cmd:String) {
|
fun sendCmd(cmd: String) {
|
||||||
lifecycleScope.launch {
|
ScaleManager.sendCmd(cmd)
|
||||||
val result = SerialPortManager.send(cmd)
|
log("发送命令: $cmd")
|
||||||
log("发送命令${cmd}结果:${result}")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val fileLogger by lazy { FileLogger(this) }
|
val fileLogger by lazy { FileLogger(this) }
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.shuwei.intelligent.shelves.serial
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 心跳数据(命令 0x01 / 0x81,终端发起)
|
||||||
|
*
|
||||||
|
* @param scaleCount 秤的数量
|
||||||
|
* @param weights 各台秤的重量列表,索引从1开始
|
||||||
|
* @param simCardNo 物联卡号(20字节ASCII)
|
||||||
|
* @param lock1Door 锁1门状态:0-关 1-开
|
||||||
|
* @param lock1Lock 锁1锁状态:0-关 1-开
|
||||||
|
* @param lock2Door 锁2门状态:0-关 1-开
|
||||||
|
* @param lock2Lock 锁2锁状态:0-关 1-开
|
||||||
|
* @param temperature 温度值(原始字节,单位由温控器决定)
|
||||||
|
*/
|
||||||
|
data class HeartbeatData(
|
||||||
|
val scaleCount: Int,
|
||||||
|
val weights: List<ScaleWeight>,
|
||||||
|
val simCardNo: String,
|
||||||
|
val lock1Door: Int,
|
||||||
|
val lock1Lock: Int,
|
||||||
|
val lock2Door: Int,
|
||||||
|
val lock2Lock: Int,
|
||||||
|
val temperature: Int
|
||||||
|
)
|
||||||
@@ -144,7 +144,9 @@ object ProtocolConstants {
|
|||||||
//原410货柜
|
//原410货柜
|
||||||
"7a991439-3a12-3ef7-809b-c0258b839473",
|
"7a991439-3a12-3ef7-809b-c0258b839473",
|
||||||
//原1楼餐厅货柜
|
//原1楼餐厅货柜
|
||||||
"4787e213-90ab-3e32-88e0-ac271a937751"
|
"4787e213-90ab-3e32-88e0-ac271a937751",
|
||||||
|
//新设备
|
||||||
|
"c9ffac4b-231c-352d-b553-919136c3fe1b"
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.shuwei.intelligent.shelves.serial
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 协议帧通用数据结构
|
||||||
|
* 每一帧解析后的原始信息,所有命令共用此结构
|
||||||
|
*
|
||||||
|
* @param cmd 命令字节,对应 ScaleProtocolConstants.CMD_* 常量
|
||||||
|
* @param flag 标志字节
|
||||||
|
* @param rawBody 数据体原始字节(不含协议头尾、命令、标志、固定数据体)
|
||||||
|
* @param terminalId 终端ID(15字节),ASCII字符串
|
||||||
|
* @param firmwareVersion 固件版本(5字节),ASCII字符串
|
||||||
|
* @param cmdCount 命令次数
|
||||||
|
*/
|
||||||
|
data class ScaleFrame(
|
||||||
|
val cmd: Byte,
|
||||||
|
val flag: Byte,
|
||||||
|
val rawBody: ByteArray,
|
||||||
|
val terminalId: String,
|
||||||
|
val firmwareVersion: String,
|
||||||
|
val cmdCount: Int
|
||||||
|
) {
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (other !is ScaleFrame) return false
|
||||||
|
return cmd == other.cmd &&
|
||||||
|
flag == other.flag &&
|
||||||
|
rawBody.contentEquals(other.rawBody) &&
|
||||||
|
terminalId == other.terminalId &&
|
||||||
|
firmwareVersion == other.firmwareVersion &&
|
||||||
|
cmdCount == other.cmdCount
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var result = cmd.toInt()
|
||||||
|
result = 31 * result + flag.toInt()
|
||||||
|
result = 31 * result + rawBody.contentHashCode()
|
||||||
|
result = 31 * result + terminalId.hashCode()
|
||||||
|
result = 31 * result + firmwareVersion.hashCode()
|
||||||
|
result = 31 * result + cmdCount
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
package com.shuwei.intelligent.shelves.serial
|
||||||
|
|
||||||
|
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.BODY_OFFSET
|
||||||
|
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.BYTES_PER_WEIGHT
|
||||||
|
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.FIRMWARE_VERSION_LEN
|
||||||
|
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.FIXED_BODY_LEN
|
||||||
|
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.FOOTER_HEX
|
||||||
|
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.FOOTER_LEN
|
||||||
|
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.HEADER_HEX
|
||||||
|
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.SIM_CARD_NO_LEN
|
||||||
|
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.TERMINAL_ID_LEN
|
||||||
|
import com.shuwei.intelligent.shelves.utils.hexToBytes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 协议帧组装与解析器
|
||||||
|
*
|
||||||
|
* 职责一:将串口原始分片十六进制字符串拼装为完整协议帧
|
||||||
|
* 职责二:将完整帧解析为 ScaleFrame,自动提取固定数据体字段
|
||||||
|
* 职责三:提供心跳、状态等数据体的具体解析方法
|
||||||
|
* 职责四:提供重量字节解析工具(BCD编码、小端序、最高位符号位)
|
||||||
|
*
|
||||||
|
* 设计为无业务依赖,可独立复制到其他项目使用
|
||||||
|
*/
|
||||||
|
class ScaleFrameParser {
|
||||||
|
|
||||||
|
/** 分片拼装缓冲区 */
|
||||||
|
private val buffer = StringBuilder()
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// 一、帧组装(串口分片 -> 完整帧十六进制字符串)
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追加串口原始分片数据,尝试拼装为完整帧
|
||||||
|
*
|
||||||
|
* @param srcData 串口收到的原始十六进制字符串片段
|
||||||
|
* @return 完整协议帧字符串;帧尚不完整时返回 null
|
||||||
|
*/
|
||||||
|
fun appendData(srcData: String): String? {
|
||||||
|
val hasHeader = srcData.contains(HEADER_HEX)
|
||||||
|
val hasFooter = srcData.contains(FOOTER_HEX)
|
||||||
|
return when {
|
||||||
|
hasHeader && hasFooter -> parseCompleteInSingleChunk(srcData)
|
||||||
|
hasHeader && !hasFooter -> {
|
||||||
|
// 只有帧头,等待后续分片
|
||||||
|
buffer.clear()
|
||||||
|
buffer.append(srcData.substring(srcData.lastIndexOf(HEADER_HEX))).append(",")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
!hasHeader && !hasFooter -> {
|
||||||
|
// 中间分片,追加缓冲
|
||||||
|
buffer.append(srcData).append(",")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// 只有帧尾,与缓冲区拼接成完整帧
|
||||||
|
val endIndex = srcData.indexOf(FOOTER_HEX) + FOOTER_HEX.length
|
||||||
|
buffer.append(srcData.substring(0, endIndex))
|
||||||
|
val frame = buffer.toString().replace(",", "")
|
||||||
|
buffer.clear()
|
||||||
|
frame.takeIf { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理单个分片中同时含有帧头和帧尾的情况
|
||||||
|
*/
|
||||||
|
private fun parseCompleteInSingleChunk(srcData: String): String? {
|
||||||
|
buffer.clear()
|
||||||
|
val startIndex = srcData.lastIndexOf(HEADER_HEX)
|
||||||
|
val endIndex = srcData.indexOf(FOOTER_HEX) + FOOTER_HEX.length
|
||||||
|
return if (endIndex <= startIndex) {
|
||||||
|
// 帧尾在帧头之前:是上一帧的残留尾部 + 新帧头部
|
||||||
|
val partial = srcData.substring(startIndex)
|
||||||
|
if (partial.contains(FOOTER_HEX)) {
|
||||||
|
val newEnd = partial.indexOf(FOOTER_HEX) + FOOTER_HEX.length
|
||||||
|
partial.substring(0, newEnd)
|
||||||
|
} else {
|
||||||
|
buffer.append(partial).append(",")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 正常完整帧
|
||||||
|
srcData.substring(startIndex, endIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清空分片缓冲区 */
|
||||||
|
fun reset() {
|
||||||
|
buffer.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// 二、帧解析(完整帧十六进制字符串 -> ScaleFrame)
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析完整协议帧为 ScaleFrame
|
||||||
|
*
|
||||||
|
* 帧结构:[协议头4] [CMD1] [FLAG1] [数据体N] [终端ID15] [固件版本5] [命令次数1] [协议尾2]
|
||||||
|
*
|
||||||
|
* @param hexFrame 完整帧十六进制字符串(含协议头尾)
|
||||||
|
* @return 解析成功返回 ScaleFrame;校验失败或格式错误返回 null
|
||||||
|
*/
|
||||||
|
fun parseFrame(hexFrame: String): ScaleFrame? {
|
||||||
|
// 最小帧:协议头(8) + cmd(2) + flag(2) + 固定数据体(42) + 协议尾(4) = 58 字符
|
||||||
|
if (hexFrame.length < 58) return null
|
||||||
|
if (!hexFrame.startsWith(HEADER_HEX) || !hexFrame.endsWith(FOOTER_HEX)) return null
|
||||||
|
|
||||||
|
return runCatching {
|
||||||
|
val bytes = hexFrame.hexToBytes()
|
||||||
|
val totalLen = bytes.size
|
||||||
|
|
||||||
|
val cmd = bytes[ScaleProtocolConstants.HEADER_LEN]
|
||||||
|
val flag = bytes[ScaleProtocolConstants.HEADER_LEN + 1]
|
||||||
|
|
||||||
|
// 固定数据体位于协议尾之前
|
||||||
|
val fixedBodyStart = totalLen - FOOTER_LEN - FIXED_BODY_LEN
|
||||||
|
if (fixedBodyStart < BODY_OFFSET) return null
|
||||||
|
|
||||||
|
val fixedBody = bytes.copyOfRange(fixedBodyStart, totalLen - FOOTER_LEN)
|
||||||
|
|
||||||
|
// 解析固定数据体三个字段
|
||||||
|
val terminalId = String(fixedBody, 0, TERMINAL_ID_LEN, Charsets.US_ASCII)
|
||||||
|
val firmwareVersion = String(fixedBody, TERMINAL_ID_LEN, FIRMWARE_VERSION_LEN, Charsets.US_ASCII)
|
||||||
|
val cmdCount = fixedBody[TERMINAL_ID_LEN + FIRMWARE_VERSION_LEN].toInt() and 0xFF
|
||||||
|
|
||||||
|
// 命令专属数据体(固定数据体和协议头尾之间的部分)
|
||||||
|
val rawBody = bytes.copyOfRange(BODY_OFFSET, fixedBodyStart)
|
||||||
|
|
||||||
|
ScaleFrame(
|
||||||
|
cmd = cmd,
|
||||||
|
flag = flag,
|
||||||
|
rawBody = rawBody,
|
||||||
|
terminalId = terminalId,
|
||||||
|
firmwareVersion = firmwareVersion,
|
||||||
|
cmdCount = cmdCount
|
||||||
|
)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// 三、数据体解析
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析心跳数据体(命令 0x01 / 0x81,终端发起)
|
||||||
|
*
|
||||||
|
* rawBody 格式:
|
||||||
|
* [秤数量 1] [秤重量 N×4] [物联卡号 20] [锁1门 1] [锁1锁 1] [锁2门 1] [锁2锁 1] [温度 1]
|
||||||
|
*
|
||||||
|
* @param body ScaleFrame.rawBody
|
||||||
|
* @return 解析成功返回 HeartbeatData;数据不足返回 null
|
||||||
|
*/
|
||||||
|
fun parseHeartbeat(body: ByteArray): HeartbeatData? {
|
||||||
|
if (body.isEmpty()) return null
|
||||||
|
return runCatching {
|
||||||
|
var offset = 0
|
||||||
|
|
||||||
|
val scaleCount = body[offset++].toInt() and 0xFF
|
||||||
|
val minBodyLen = 1 + scaleCount * BYTES_PER_WEIGHT + SIM_CARD_NO_LEN + 4 + 1
|
||||||
|
if (body.size < minBodyLen) return null
|
||||||
|
|
||||||
|
val weights = (1..scaleCount).map { index ->
|
||||||
|
val grams = parseWeightBytes(body, offset)
|
||||||
|
offset += BYTES_PER_WEIGHT
|
||||||
|
ScaleWeight(index, grams)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 物联卡号:ASCII字符串,去除尾部空字符
|
||||||
|
val simCardNo = String(body, offset, SIM_CARD_NO_LEN, Charsets.US_ASCII)
|
||||||
|
.trimEnd('\u0000').trim()
|
||||||
|
offset += SIM_CARD_NO_LEN
|
||||||
|
|
||||||
|
val lock1Door = body[offset++].toInt() and 0xFF
|
||||||
|
val lock1Lock = body[offset++].toInt() and 0xFF
|
||||||
|
val lock2Door = body[offset++].toInt() and 0xFF
|
||||||
|
val lock2Lock = body[offset++].toInt() and 0xFF
|
||||||
|
val temperature = body[offset].toInt() and 0xFF
|
||||||
|
|
||||||
|
HeartbeatData(
|
||||||
|
scaleCount = scaleCount,
|
||||||
|
weights = weights,
|
||||||
|
simCardNo = simCardNo,
|
||||||
|
lock1Door = lock1Door,
|
||||||
|
lock1Lock = lock1Lock,
|
||||||
|
lock2Door = lock2Door,
|
||||||
|
lock2Lock = lock2Lock,
|
||||||
|
temperature = temperature
|
||||||
|
)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析获取状态响应数据体(命令 0x03,终端响应)
|
||||||
|
*
|
||||||
|
* rawBody 格式:
|
||||||
|
* [锁1锁 1] [锁1门 1] [锁2锁 1] [锁2门 1] [秤数量 1] [秤重量 N×4] [温度 1]
|
||||||
|
*
|
||||||
|
* @param body ScaleFrame.rawBody
|
||||||
|
* @return 解析成功返回 StatusData;数据不足返回 null
|
||||||
|
*/
|
||||||
|
fun parseStatus(body: ByteArray): StatusData? {
|
||||||
|
if (body.size < 6) return null
|
||||||
|
return runCatching {
|
||||||
|
var offset = 0
|
||||||
|
|
||||||
|
val lock1Lock = body[offset++].toInt() and 0xFF
|
||||||
|
val lock1Door = body[offset++].toInt() and 0xFF
|
||||||
|
val lock2Lock = body[offset++].toInt() and 0xFF
|
||||||
|
val lock2Door = body[offset++].toInt() and 0xFF
|
||||||
|
val scaleCount = body[offset++].toInt() and 0xFF
|
||||||
|
|
||||||
|
val minBodyLen = 5 + scaleCount * BYTES_PER_WEIGHT + 1
|
||||||
|
if (body.size < minBodyLen) return null
|
||||||
|
|
||||||
|
val weights = (1..scaleCount).map { index ->
|
||||||
|
val grams = parseWeightBytes(body, offset)
|
||||||
|
offset += BYTES_PER_WEIGHT
|
||||||
|
ScaleWeight(index, grams)
|
||||||
|
}
|
||||||
|
|
||||||
|
val temperature = body[offset].toInt() and 0xFF
|
||||||
|
|
||||||
|
StatusData(
|
||||||
|
lock1Lock = lock1Lock,
|
||||||
|
lock1Door = lock1Door,
|
||||||
|
lock2Lock = lock2Lock,
|
||||||
|
lock2Door = lock2Door,
|
||||||
|
scaleCount = scaleCount,
|
||||||
|
weights = weights,
|
||||||
|
temperature = temperature
|
||||||
|
)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// 四、重量解析工具
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析4字节小端序重量数据
|
||||||
|
*
|
||||||
|
* 编码规则(BCD + 小端序 + 最高位符号位):
|
||||||
|
* - 4字节小端序存储,低字节在前,高字节在后
|
||||||
|
* - 高字节最高位(bit7)为符号位:0=正数,1=负数
|
||||||
|
* - 其余位组成8位BCD十进制字符串,作为十进制整数读取,单位为克
|
||||||
|
*
|
||||||
|
* 示例:
|
||||||
|
* [0x13, 0x10, 0x00, 0x00] → 大端序 "00001013" → 十进制 1013g = 1.013KG
|
||||||
|
* [0x13, 0x01, 0x02, 0x00] → 大端序 "00020113" → 十进制 2113g = 20.113KG(注:原例实为20113g,见下)
|
||||||
|
* [0x13, 0x10, 0x00, 0x80] → 大端序 "80001013" → 符号位=1 → 十进制 -1013g = -1.013KG
|
||||||
|
*
|
||||||
|
* 注意:大端序十六进制字符串按十进制解析(非十六进制),这是BCD编码的关键
|
||||||
|
*
|
||||||
|
* @param bytes 字节数组
|
||||||
|
* @param offset 起始偏移量(连续4字节)
|
||||||
|
* @return 重量(克),负数表示低于去皮零点
|
||||||
|
*/
|
||||||
|
fun parseWeightBytes(bytes: ByteArray, offset: Int): Int {
|
||||||
|
val b0 = bytes[offset].toInt() and 0xFF // 最低字节
|
||||||
|
val b1 = bytes[offset + 1].toInt() and 0xFF
|
||||||
|
val b2 = bytes[offset + 2].toInt() and 0xFF
|
||||||
|
val b3 = bytes[offset + 3].toInt() and 0xFF // 最高字节(含符号位)
|
||||||
|
|
||||||
|
// 检查最高字节的最高位是否为1(负数标志)
|
||||||
|
val isNegative = (b3 and 0x80) != 0
|
||||||
|
val b3Cleared = b3 and 0x7F // 清除符号位
|
||||||
|
|
||||||
|
// 构造大端序8位十六进制字符串
|
||||||
|
val hexStr = "%02X%02X%02X%02X".format(b3Cleared, b2, b1, b0)
|
||||||
|
|
||||||
|
// 将十六进制字符串作为十进制整数解析(BCD解码)
|
||||||
|
// "00001013" → toLong() = 1013,而非 toLong(16) = 4115
|
||||||
|
val absValue = hexStr.toLongOrNull() ?: 0L
|
||||||
|
|
||||||
|
return if (isNegative) -absValue.toInt() else absValue.toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,614 @@
|
|||||||
|
package com.shuwei.intelligent.shelves.serial
|
||||||
|
|
||||||
|
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.CMD_ACTIVATE
|
||||||
|
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.CMD_CARD_UPLOAD
|
||||||
|
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.CMD_GET_STATUS
|
||||||
|
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.CMD_HEARTBEAT
|
||||||
|
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.CMD_HEARTBEAT_STORE
|
||||||
|
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.CMD_OPERATION_RECORD
|
||||||
|
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.CMD_POSITION_REPORT
|
||||||
|
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.CMD_TEMP_REPORT
|
||||||
|
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.CMD_TIME_SYNC
|
||||||
|
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.CMD_UNLOCK
|
||||||
|
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.FOOTER_HEX
|
||||||
|
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.HEADER_HEX
|
||||||
|
import com.shuwei.intelligent.shelves.utils.hexToBytes
|
||||||
|
import com.shuwei.intelligent.shelves.utils.toHexString2
|
||||||
|
import io.github.jeadyx.jserialport.AndroidSerialPort
|
||||||
|
import io.github.jeadyx.jserialport.SerialPort
|
||||||
|
import io.github.jeadyx.jserialport.SerialPortFactory
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.TimeZone
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 称重控制板管理器(单例入口)
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* - 管理串口连接生命周期(start/release)
|
||||||
|
* - 通过内部发送队列(Channel)保证指令按顺序写入串口
|
||||||
|
* - 接收串口原始分片数据,自动拼装帧并分发到对应回调
|
||||||
|
* - 内置各命令的自动应答逻辑(心跳/对时/卡号等)
|
||||||
|
* - 提供链式回调注册 和 常用指令构建
|
||||||
|
*
|
||||||
|
* 典型用法:
|
||||||
|
* ```kotlin
|
||||||
|
* lifecycleScope.launch {
|
||||||
|
* ScaleManager
|
||||||
|
* .init(activateCode = ACTIVE_MAP[deviceId] ?: "")
|
||||||
|
* .onHeartbeat { _, data ->
|
||||||
|
* data.weights.forEach { Log.d("Scale", "秤${it.index}: ${it.grams}g") }
|
||||||
|
* }
|
||||||
|
* .onLog { msg -> Log.d("Scale", msg) }
|
||||||
|
* .start { data ->
|
||||||
|
* // 主线程前置钩子,处理看门狗计时等副作用
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* // 主动发送指令
|
||||||
|
* ScaleManager.sendCmd(ScaleManager.buildGetStatusCmd())
|
||||||
|
*
|
||||||
|
* // 页面销毁时释放(关闭串口 + 清空队列 + 清理回调)
|
||||||
|
* runBlocking { ScaleManager.release() }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
object ScaleManager {
|
||||||
|
|
||||||
|
/** 串口端口名,默认 /dev/ttyS1 */
|
||||||
|
var portName: String = "/dev/ttyS1"
|
||||||
|
/** 串口波特率,默认 115200 */
|
||||||
|
var baudRate: Int = 115200
|
||||||
|
|
||||||
|
private val parser = ScaleFrameParser()
|
||||||
|
private var serialPort: AndroidSerialPort? = null
|
||||||
|
private var scope = CoroutineScope(Dispatchers.IO)
|
||||||
|
|
||||||
|
/** 发送队列:保证串口写入顺序,避免并发写冲突 */
|
||||||
|
private val sendChannel = Channel<String>(capacity = Channel.UNLIMITED)
|
||||||
|
|
||||||
|
/** 当前设备激活码,非空时收到激活请求自动应答 */
|
||||||
|
private var activateCode: String = ""
|
||||||
|
|
||||||
|
/** 心跳应答:心跳上报间隔(秒),默认5秒 */
|
||||||
|
var heartbeatInterval: Int = 5
|
||||||
|
|
||||||
|
/** 心跳应答:开锁后未开门自动落锁时间(秒),默认6秒 */
|
||||||
|
var lockTimeout: Int = 6
|
||||||
|
|
||||||
|
// 各命令监听列表
|
||||||
|
private val heartbeatListeners = mutableListOf<(ScaleFrame, HeartbeatData) -> Unit>()
|
||||||
|
private val statusListeners = mutableListOf<(ScaleFrame, StatusData) -> Unit>()
|
||||||
|
private val lockListeners = mutableListOf<(ScaleFrame) -> Unit>()
|
||||||
|
private val cardListeners = mutableListOf<(ScaleFrame) -> Unit>()
|
||||||
|
private val operationListeners = mutableListOf<(ScaleFrame) -> Unit>()
|
||||||
|
private val timeSyncListeners = mutableListOf<(ScaleFrame) -> Unit>()
|
||||||
|
private val positionListeners = mutableListOf<(ScaleFrame) -> Unit>()
|
||||||
|
private val tempReportListeners = mutableListOf<(ScaleFrame) -> Unit>()
|
||||||
|
private val activateListeners = mutableListOf<(ScaleFrame) -> Unit>()
|
||||||
|
/** 任意命令的原始帧监听,用于协议中尚未提供专项解析的命令 */
|
||||||
|
private val rawListeners = mutableMapOf<Byte, MutableList<(ScaleFrame) -> Unit>>()
|
||||||
|
private val logListeners = mutableListOf<(String) -> Unit>()
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// 初始化
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化,设置设备激活码
|
||||||
|
*
|
||||||
|
* @param activateCode 当前设备的激活应答帧十六进制字符串,非空时收到激活请求自动应答
|
||||||
|
* @return this,支持链式调用
|
||||||
|
*/
|
||||||
|
fun init(activateCode: String = ""): ScaleManager {
|
||||||
|
this.activateCode = activateCode
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// 串口生命周期
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开串口并启动接收,在回调注册完成后调用(链式末尾)
|
||||||
|
*
|
||||||
|
* @param preHook 每帧数据到达时的前置钩子(主线程),用于看门狗计时、UI副作用等
|
||||||
|
* @return true=串口打开成功并已启动接收,false=串口打开失败
|
||||||
|
*/
|
||||||
|
suspend fun start(preHook: ((String) -> Unit)? = null): Boolean {
|
||||||
|
if (!openPort()) return false
|
||||||
|
startReceive(preHook)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun openPort(): Boolean {
|
||||||
|
if (!scope.isActive) {
|
||||||
|
scope = CoroutineScope(Dispatchers.IO)
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
serialPort = SerialPortFactory.create().apply {
|
||||||
|
open(
|
||||||
|
portName = portName,
|
||||||
|
baudRate = baudRate,
|
||||||
|
dataBits = 8,
|
||||||
|
stopBits = 1,
|
||||||
|
parity = SerialPort.PARITY_NONE
|
||||||
|
)
|
||||||
|
} as AndroidSerialPort?
|
||||||
|
// 启动单一消费者协程,顺序消费发送队列
|
||||||
|
scope.launch {
|
||||||
|
for (hexStr in sendChannel) {
|
||||||
|
try {
|
||||||
|
serialPort?.write(hexStr.hexToBytes())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
log("发送命令失败: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startReceive(preHook: ((String) -> Unit)? = null) {
|
||||||
|
scope.launch {
|
||||||
|
while (isActive) {
|
||||||
|
try {
|
||||||
|
val flow = serialPort?.read()
|
||||||
|
flow?.collect { buffer ->
|
||||||
|
val data = buffer.toHexString2().uppercase(Locale.ROOT)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
try {
|
||||||
|
preHook?.invoke(data)
|
||||||
|
onDataReceived(data)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
log("串口数据处理异常: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
delay(50)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// 数据接收入口
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 接收串口原始数据(在串口回调中调用此方法)
|
||||||
|
*
|
||||||
|
* 内部自动完成分片拼装 → 帧解析 → 命令分发 → 自动应答
|
||||||
|
*
|
||||||
|
* @param hexData 串口收到的十六进制字符串(可能为分片数据)
|
||||||
|
*/
|
||||||
|
fun onDataReceived(hexData: String) {
|
||||||
|
val completeFrame = parser.appendData(hexData) ?: return
|
||||||
|
val frame = parser.parseFrame(completeFrame) ?: run {
|
||||||
|
log("帧解析失败,原始数据:$completeFrame")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dispatch(frame, completeFrame)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// 回调注册(链式调用)
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册心跳回调(命令 0x01 / 0x81,终端发起)
|
||||||
|
* 回调参数:[ScaleFrame 帧信息] [HeartbeatData 心跳数据(含重量、锁状态、温度)]
|
||||||
|
*/
|
||||||
|
fun onHeartbeat(listener: (ScaleFrame, HeartbeatData) -> Unit): ScaleManager {
|
||||||
|
heartbeatListeners.add(listener)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册获取状态回调(命令 0x03,服务端请求后终端响应)
|
||||||
|
* 回调参数:[ScaleFrame 帧信息] [StatusData 状态数据(含重量、锁状态、温度)]
|
||||||
|
*/
|
||||||
|
fun onStatus(listener: (ScaleFrame, StatusData) -> Unit): ScaleManager {
|
||||||
|
statusListeners.add(listener)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册开锁反馈回调(命令 0x02,终端发起,关门后上报)
|
||||||
|
* 回调参数:[ScaleFrame 帧信息],锁状态等信息在 ScaleFrame.rawBody 中
|
||||||
|
*/
|
||||||
|
fun onLockFeedback(listener: (ScaleFrame) -> Unit): ScaleManager {
|
||||||
|
lockListeners.add(listener)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册卡号上发回调(命令 0x05,终端发起)
|
||||||
|
* 回调参数:[ScaleFrame 帧信息],卡号等信息在 ScaleFrame.rawBody 中
|
||||||
|
*/
|
||||||
|
fun onCardUpload(listener: (ScaleFrame) -> Unit): ScaleManager {
|
||||||
|
cardListeners.add(listener)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册操作记录上传回调(命令 0x0B,终端发起)
|
||||||
|
* 回调参数:[ScaleFrame 帧信息],操作记录在 ScaleFrame.rawBody 中
|
||||||
|
*/
|
||||||
|
fun onOperationRecord(listener: (ScaleFrame) -> Unit): ScaleManager {
|
||||||
|
operationListeners.add(listener)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册对时回调(命令 0x0C,终端发起)
|
||||||
|
* 管理器已自动回复当前时间戳,此回调用于业务侧感知对时事件
|
||||||
|
*/
|
||||||
|
fun onTimeSync(listener: (ScaleFrame) -> Unit): ScaleManager {
|
||||||
|
timeSyncListeners.add(listener)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册终端位置上报回调(命令 0x0E,终端发起,仅4G版本)
|
||||||
|
* 回调参数:[ScaleFrame 帧信息],基站信息(MCC/MNC/LAC/CI)在 ScaleFrame.rawBody 中
|
||||||
|
*/
|
||||||
|
fun onPositionReport(listener: (ScaleFrame) -> Unit): ScaleManager {
|
||||||
|
positionListeners.add(listener)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册温控信息上报回调(命令 0x10,终端发起)
|
||||||
|
* 回调参数:[ScaleFrame 帧信息],寄存器内容在 ScaleFrame.rawBody 中
|
||||||
|
*/
|
||||||
|
fun onTempReport(listener: (ScaleFrame) -> Unit): ScaleManager {
|
||||||
|
tempReportListeners.add(listener)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册终端激活回调(命令 0x00,终端发起)
|
||||||
|
* 注意:激活应答(含激活码)需在此回调中手动调用 sendCmd() 发送
|
||||||
|
*/
|
||||||
|
fun onActivate(listener: (ScaleFrame) -> Unit): ScaleManager {
|
||||||
|
activateListeners.add(listener)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册任意命令的原始帧监听(用于协议中尚未专项解析的命令,或扩展监听)
|
||||||
|
*
|
||||||
|
* @param cmd 命令字节,对应 ScaleProtocolConstants.CMD_* 常量
|
||||||
|
*/
|
||||||
|
fun onRawFrame(cmd: Byte, listener: (ScaleFrame) -> Unit): ScaleManager {
|
||||||
|
rawListeners.getOrPut(cmd) { mutableListOf() }.add(listener)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册日志回调,所有内部日志通过此回调输出
|
||||||
|
*/
|
||||||
|
fun onLog(listener: (String) -> Unit): ScaleManager {
|
||||||
|
logListeners.add(listener)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// 发送指令
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送十六进制字符串指令,指令进入发送队列按顺序写入串口
|
||||||
|
*
|
||||||
|
* @param hexStr 完整帧十六进制字符串,含协议头尾
|
||||||
|
*/
|
||||||
|
fun sendCmd(hexStr: String) {
|
||||||
|
if (!scope.isActive) {
|
||||||
|
log("警告:ScaleManager 串口未打开或已释放")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendChannel.trySend(hexStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// 常用指令构建
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建获取设备状态指令
|
||||||
|
* 协议:CMD=03 FLAG=01 [预留12字节]
|
||||||
|
*/
|
||||||
|
fun buildGetStatusCmd(): String =
|
||||||
|
"${HEADER_HEX}0301${"00".repeat(12)}${FOOTER_HEX}"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建开锁/控制指令
|
||||||
|
*
|
||||||
|
* @param lock1 锁1操作:1=开 0=关 其他值(如0xFF)=不动作
|
||||||
|
* @param lock2 锁2操作:1=开 0=关 其他值=不动作
|
||||||
|
* @param relay1 继电器1:1=开 0=关 其他值=不动作
|
||||||
|
* @param relay2 继电器2:1=开 0=关 其他值=不动作
|
||||||
|
* @param relay3 继电器3:1=开 0=关 其他值=不动作
|
||||||
|
* @param orderId 订单号,超出14字节截断,不足补零
|
||||||
|
*/
|
||||||
|
fun buildUnlockCmd(
|
||||||
|
lock1: Int = 0xFF,
|
||||||
|
lock2: Int = 0xFF,
|
||||||
|
relay1: Int = 0xFF,
|
||||||
|
relay2: Int = 0xFF,
|
||||||
|
relay3: Int = 0xFF,
|
||||||
|
orderId: String = ""
|
||||||
|
): String {
|
||||||
|
val orderBytes = orderId.toByteArray(Charsets.US_ASCII).copyOf(14)
|
||||||
|
val orderHex = orderBytes.joinToString("") { "%02X".format(it) }
|
||||||
|
return "${HEADER_HEX}0201" +
|
||||||
|
"%02X%02X%02X%02X%02X".format(lock1 and 0xFF, lock2 and 0xFF, relay1 and 0xFF, relay2 and 0xFF, relay3 and 0xFF) +
|
||||||
|
orderHex +
|
||||||
|
FOOTER_HEX
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建开锁1指令(左门)
|
||||||
|
* @param orderId 订单号,可选
|
||||||
|
*/
|
||||||
|
fun buildOpenLock1Cmd(orderId: String = ""): String =
|
||||||
|
buildUnlockCmd(lock1 = 1, lock2 = 0xFF, relay1 = 0xFF, relay2 = 0xFF, relay3 = 0xFF, orderId = orderId)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建开锁2指令(右门)
|
||||||
|
* @param orderId 订单号,可选
|
||||||
|
*/
|
||||||
|
fun buildOpenLock2Cmd(orderId: String = ""): String =
|
||||||
|
buildUnlockCmd(lock1 = 0xFF, lock2 = 1, relay1 = 0xFF, relay2 = 0xFF, relay3 = 0xFF, orderId = orderId)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建秤置零指令
|
||||||
|
* 协议:CMD=04 FLAG=01 [秤号1字节]
|
||||||
|
*
|
||||||
|
* @param scaleIndex 秤号,从1开始
|
||||||
|
*/
|
||||||
|
fun buildScaleZeroCmd(scaleIndex: Int): String =
|
||||||
|
"${HEADER_HEX}0401%02X${FOOTER_HEX}".format(scaleIndex and 0xFF)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建秤标定指令
|
||||||
|
* 协议:CMD=06 FLAG=01 [秤号1字节] [砝码值4字节小端序]
|
||||||
|
*
|
||||||
|
* @param scaleIndex 秤号,从1开始
|
||||||
|
* @param weightGrams 砝码重量(克),如10kg砝码传入10000
|
||||||
|
*/
|
||||||
|
fun buildScaleCalibrateCmd(scaleIndex: Int, weightGrams: Int): String {
|
||||||
|
// 砝码值转小端序4字节(BCD编码:克数按十进制写入十六进制位)
|
||||||
|
val weightHex = "%08d".format(weightGrams) // BCD:十进制值直接作为十六进制字符串
|
||||||
|
val leHex = weightHex.chunked(2).reversed().joinToString("")
|
||||||
|
return "${HEADER_HEX}0601%02X${leHex}${FOOTER_HEX}".format(scaleIndex and 0xFF)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建温控器控制指令
|
||||||
|
* 协议:CMD=0F FLAG=01 [操作编码1字节] [设定值2字节]
|
||||||
|
*
|
||||||
|
* @param opCode 操作编码(参考温控器说明书)
|
||||||
|
* @param setValue 设定值
|
||||||
|
*/
|
||||||
|
fun buildTempCtrlCmd(opCode: Int, setValue: Int): String =
|
||||||
|
"${HEADER_HEX}0F01%02X%04X${FOOTER_HEX}".format(opCode and 0xFF, setValue and 0xFFFF)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建临时参数设置指令
|
||||||
|
* 协议:CMD=11 FLAG=01 [参数编码1字节] [设定值1字节]
|
||||||
|
*
|
||||||
|
* @param paramCode 参数编码(1=关门语音触发条件 2=未关门语音提示 3=锁常开设置)
|
||||||
|
* @param value 设定值
|
||||||
|
*/
|
||||||
|
fun buildTempParamSetCmd(paramCode: Int, value: Int): String =
|
||||||
|
"${HEADER_HEX}1101%02X%02X${FOOTER_HEX}".format(paramCode and 0xFF, value and 0xFF)
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// 资源释放
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 释放所有资源:清空回调列表、发送函数及帧缓冲
|
||||||
|
* 通常在 Activity/Fragment 销毁或不再使用时调用
|
||||||
|
*/
|
||||||
|
suspend fun release() {
|
||||||
|
sendChannel.close()
|
||||||
|
scope.cancel()
|
||||||
|
serialPort?.close()
|
||||||
|
serialPort = null
|
||||||
|
heartbeatListeners.clear()
|
||||||
|
statusListeners.clear()
|
||||||
|
lockListeners.clear()
|
||||||
|
cardListeners.clear()
|
||||||
|
operationListeners.clear()
|
||||||
|
timeSyncListeners.clear()
|
||||||
|
positionListeners.clear()
|
||||||
|
tempReportListeners.clear()
|
||||||
|
activateListeners.clear()
|
||||||
|
rawListeners.clear()
|
||||||
|
logListeners.clear()
|
||||||
|
activateCode = ""
|
||||||
|
parser.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// 内部命令分发与自动应答
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun dispatch(frame: ScaleFrame, rawHex: String) {
|
||||||
|
when (frame.cmd) {
|
||||||
|
CMD_ACTIVATE -> handleActivate(frame, rawHex)
|
||||||
|
CMD_HEARTBEAT,
|
||||||
|
CMD_HEARTBEAT_STORE -> handleHeartbeat(frame, rawHex)
|
||||||
|
CMD_UNLOCK -> handleLockFeedback(frame, rawHex)
|
||||||
|
CMD_GET_STATUS -> handleStatus(frame, rawHex)
|
||||||
|
CMD_CARD_UPLOAD -> handleCardUpload(frame, rawHex)
|
||||||
|
CMD_OPERATION_RECORD -> handleOperationRecord(frame, rawHex)
|
||||||
|
CMD_TIME_SYNC -> handleTimeSync(frame, rawHex)
|
||||||
|
CMD_POSITION_REPORT -> handlePositionReport(frame, rawHex)
|
||||||
|
CMD_TEMP_REPORT -> handleTempReport(frame, rawHex)
|
||||||
|
else -> {
|
||||||
|
rawListeners[frame.cmd]?.forEach { it(frame) }
|
||||||
|
log("收到未处理命令:0x%02X,终端ID:${frame.terminalId},原始数据:$rawHex".format(frame.cmd))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理终端激活(CMD=0x00)
|
||||||
|
* 若 activateMap 中存在当前 deviceId 对应的激活码,则自动应答;
|
||||||
|
* 否则仍回调 onActivate,由业务方自行处理
|
||||||
|
*/
|
||||||
|
private fun handleActivate(frame: ScaleFrame, rawHex: String) {
|
||||||
|
log("收到[终端激活]指令,终端ID:${frame.terminalId},固件版本:${frame.firmwareVersion},原始数据:$rawHex")
|
||||||
|
val activeCmd = activateCode
|
||||||
|
if (!activeCmd.isNullOrBlank()) {
|
||||||
|
sendCmd(activeCmd)
|
||||||
|
log("激活码自动应答:$activeCmd")
|
||||||
|
}
|
||||||
|
activateListeners.forEach { it(frame) }
|
||||||
|
rawListeners[frame.cmd]?.forEach { it(frame) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理心跳(CMD=0x01 / 0x81)
|
||||||
|
* 自动应答:[心跳间隔] [上锁时间] [设备固件版本5字节]
|
||||||
|
*/
|
||||||
|
private fun handleHeartbeat(frame: ScaleFrame, rawHex: String) {
|
||||||
|
val firmwareHex = frame.firmwareVersion.toAsciiHex()
|
||||||
|
val resp = "${HEADER_HEX}0102%02X%02X${firmwareHex}${FOOTER_HEX}".format(
|
||||||
|
heartbeatInterval and 0xFF,
|
||||||
|
lockTimeout and 0xFF
|
||||||
|
)
|
||||||
|
sendCmd(resp)
|
||||||
|
|
||||||
|
val data = parser.parseHeartbeat(frame.rawBody)
|
||||||
|
if (data != null) {
|
||||||
|
heartbeatListeners.forEach { it(frame, data) }
|
||||||
|
}
|
||||||
|
rawListeners[frame.cmd]?.forEach { it(frame) }
|
||||||
|
log("收到[心跳]指令,终端ID:${frame.terminalId},秤数量:${data?.scaleCount ?: 0},原始数据:$rawHex")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理开锁反馈(CMD=0x02,终端发起,关门后上报)
|
||||||
|
* 自动应答:[锁号00] [终端ID 15字节] [固件版本 5字节]
|
||||||
|
*/
|
||||||
|
private fun handleLockFeedback(frame: ScaleFrame, rawHex: String) {
|
||||||
|
val terminalIdHex = frame.terminalId.toAsciiHex()
|
||||||
|
val firmwareHex = frame.firmwareVersion.toAsciiHex()
|
||||||
|
val resp = "${HEADER_HEX}020200${terminalIdHex}${firmwareHex}${FOOTER_HEX}"
|
||||||
|
sendCmd(resp)
|
||||||
|
lockListeners.forEach { it(frame) }
|
||||||
|
rawListeners[frame.cmd]?.forEach { it(frame) }
|
||||||
|
log("收到[开锁反馈]指令,终端ID:${frame.terminalId},原始数据:$rawHex")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理获取状态响应(CMD=0x03,终端响应)
|
||||||
|
* 无需应答,直接解析并分发
|
||||||
|
*/
|
||||||
|
private fun handleStatus(frame: ScaleFrame, rawHex: String) {
|
||||||
|
val data = parser.parseStatus(frame.rawBody)
|
||||||
|
if (data != null) {
|
||||||
|
statusListeners.forEach { it(frame, data) }
|
||||||
|
}
|
||||||
|
rawListeners[frame.cmd]?.forEach { it(frame) }
|
||||||
|
log("收到[获取状态]响应,终端ID:${frame.terminalId},秤数量:${data?.scaleCount ?: 0},原始数据:$rawHex")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理卡号上发(CMD=0x05,终端发起)
|
||||||
|
* 自动应答:成功 0x03
|
||||||
|
*/
|
||||||
|
private fun handleCardUpload(frame: ScaleFrame, rawHex: String) {
|
||||||
|
val resp = "${HEADER_HEX}050203${FOOTER_HEX}"
|
||||||
|
sendCmd(resp)
|
||||||
|
cardListeners.forEach { it(frame) }
|
||||||
|
rawListeners[frame.cmd]?.forEach { it(frame) }
|
||||||
|
log("收到[卡号上发]指令,终端ID:${frame.terminalId},原始数据:$rawHex")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理操作记录上传(CMD=0x0B,终端发起)
|
||||||
|
* 自动应答:成功 0x03
|
||||||
|
*/
|
||||||
|
private fun handleOperationRecord(frame: ScaleFrame, rawHex: String) {
|
||||||
|
val resp = "${HEADER_HEX}0B0203${FOOTER_HEX}"
|
||||||
|
sendCmd(resp)
|
||||||
|
operationListeners.forEach { it(frame) }
|
||||||
|
rawListeners[frame.cmd]?.forEach { it(frame) }
|
||||||
|
log("收到[操作记录上传]指令,终端ID:${frame.terminalId},原始数据:$rawHex")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理对时请求(CMD=0x0C,终端发起,仅以太网版本)
|
||||||
|
* 自动应答:当前 Unix 时间戳(含时区偏移),小端序4字节
|
||||||
|
*/
|
||||||
|
private fun handleTimeSync(frame: ScaleFrame, rawHex: String) {
|
||||||
|
val nowMs = System.currentTimeMillis()
|
||||||
|
val timeSec = nowMs / 1000 + TimeZone.getDefault().getOffset(nowMs) / 1000
|
||||||
|
// 取低32位,转为小端序4字节十六进制
|
||||||
|
val timeHex = "%08X".format(timeSec.toInt())
|
||||||
|
.chunked(2).reversed().joinToString("")
|
||||||
|
val resp = "${HEADER_HEX}0C02${timeHex}${FOOTER_HEX}"
|
||||||
|
sendCmd(resp)
|
||||||
|
timeSyncListeners.forEach { it(frame) }
|
||||||
|
rawListeners[frame.cmd]?.forEach { it(frame) }
|
||||||
|
log("收到[对时]请求,终端ID:${frame.terminalId},时间戳:${timeSec},应答:$resp")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理终端位置上报(CMD=0x0E,终端发起,仅4G版本)
|
||||||
|
* 自动应答:保留字节 0x00
|
||||||
|
*/
|
||||||
|
private fun handlePositionReport(frame: ScaleFrame, rawHex: String) {
|
||||||
|
val resp = "${HEADER_HEX}0E0200${FOOTER_HEX}"
|
||||||
|
sendCmd(resp)
|
||||||
|
positionListeners.forEach { it(frame) }
|
||||||
|
rawListeners[frame.cmd]?.forEach { it(frame) }
|
||||||
|
log("收到[终端位置上报]指令,终端ID:${frame.terminalId},原始数据:$rawHex")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理温控信息上报(CMD=0x10,终端发起)
|
||||||
|
* 自动应答:保留字节 0x00
|
||||||
|
*/
|
||||||
|
private fun handleTempReport(frame: ScaleFrame, rawHex: String) {
|
||||||
|
val resp = "${HEADER_HEX}100200${FOOTER_HEX}"
|
||||||
|
sendCmd(resp)
|
||||||
|
tempReportListeners.forEach { it(frame) }
|
||||||
|
rawListeners[frame.cmd]?.forEach { it(frame) }
|
||||||
|
log("收到[温控信息上报]指令,终端ID:${frame.terminalId},原始数据:$rawHex")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// 私有工具
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun log(message: String) {
|
||||||
|
logListeners.forEach { it(message) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 ASCII 字符串转为十六进制字符串,用于指令构造
|
||||||
|
* 例:"V2.10" → "56322E3130"
|
||||||
|
*/
|
||||||
|
private fun String.toAsciiHex(): String =
|
||||||
|
toByteArray(Charsets.US_ASCII).joinToString("") { "%02X".format(it) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package com.shuwei.intelligent.shelves.serial
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 称重控制板接口协议常量
|
||||||
|
* 统一管理协议头尾、所有命令字节及帧结构偏移量
|
||||||
|
* 设计为独立常量对象,不依赖任何业务类,可复制到其他项目直接使用
|
||||||
|
*/
|
||||||
|
object ScaleProtocolConstants {
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// 协议头尾
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
|
/** 协议头字节数组 */
|
||||||
|
val HEADER: ByteArray = byteArrayOf(0xD6.toByte(), 0xDA.toByte(), 0xDE.toByte(), 0xDB.toByte())
|
||||||
|
|
||||||
|
/** 协议头十六进制字符串 */
|
||||||
|
const val HEADER_HEX: String = "D6DADEDB"
|
||||||
|
|
||||||
|
/** 协议尾字节数组 */
|
||||||
|
val FOOTER: ByteArray = byteArrayOf(0xFF.toByte(), 0xFF.toByte())
|
||||||
|
|
||||||
|
/** 协议尾十六进制字符串 */
|
||||||
|
const val FOOTER_HEX: String = "FFFF"
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// 命令字节(cmd 字段,1字节)
|
||||||
|
// 发起方说明:终端=秤控制板主动上报;服务端=Android端主动发送
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
|
/** 0x00 终端激活(终端发起) */
|
||||||
|
const val CMD_ACTIVATE: Byte = 0x00
|
||||||
|
|
||||||
|
/** 0x01 心跳(终端发起) */
|
||||||
|
const val CMD_HEARTBEAT: Byte = 0x01
|
||||||
|
|
||||||
|
/** 0x81 心跳(终端发起,需存储串口屏显示信息) */
|
||||||
|
const val CMD_HEARTBEAT_STORE: Byte = 0x81.toByte()
|
||||||
|
|
||||||
|
/** 0x02 开锁(服务端发起) */
|
||||||
|
const val CMD_UNLOCK: Byte = 0x02
|
||||||
|
|
||||||
|
/** 0x03 获取状态(服务端发起) */
|
||||||
|
const val CMD_GET_STATUS: Byte = 0x03
|
||||||
|
|
||||||
|
/** 0x04 秤置零(服务端发起) */
|
||||||
|
const val CMD_SCALE_ZERO: Byte = 0x04
|
||||||
|
|
||||||
|
/** 0x05 卡号上发(终端发起) */
|
||||||
|
const val CMD_CARD_UPLOAD: Byte = 0x05
|
||||||
|
|
||||||
|
/** 0x06 秤标定(服务端发起) */
|
||||||
|
const val CMD_SCALE_CALIBRATE: Byte = 0x06
|
||||||
|
|
||||||
|
/** 0x07 价签设置(服务端发起) */
|
||||||
|
const val CMD_PRICE_TAG_SET: Byte = 0x07
|
||||||
|
|
||||||
|
/** 0x08 串口屏(服务端发起) */
|
||||||
|
const val CMD_SERIAL_SCREEN: Byte = 0x08
|
||||||
|
|
||||||
|
/** 0x09 语音播报(服务端发起) */
|
||||||
|
const val CMD_VOICE_BROADCAST: Byte = 0x09
|
||||||
|
|
||||||
|
/** 0x0A 价签(第二块串口屏/透传)设置(服务端发起) */
|
||||||
|
const val CMD_PRICE_TAG_SET2: Byte = 0x0A
|
||||||
|
|
||||||
|
/** 0x0B 操作记录上传(终端发起) */
|
||||||
|
const val CMD_OPERATION_RECORD: Byte = 0x0B
|
||||||
|
|
||||||
|
/** 0x0C 对时(终端发起) */
|
||||||
|
const val CMD_TIME_SYNC: Byte = 0x0C
|
||||||
|
|
||||||
|
/** 0x0E 终端位置上报(终端发起) */
|
||||||
|
const val CMD_POSITION_REPORT: Byte = 0x0E
|
||||||
|
|
||||||
|
/** 0x0F 温控器控制(服务端发起) */
|
||||||
|
const val CMD_TEMP_CTRL: Byte = 0x0F
|
||||||
|
|
||||||
|
/** 0x10 温控信息上报(终端发起) */
|
||||||
|
const val CMD_TEMP_REPORT: Byte = 0x10
|
||||||
|
|
||||||
|
/** 0x11 临时参数设置(服务端发起) */
|
||||||
|
const val CMD_TEMP_PARAM_SET: Byte = 0x11
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// 帧结构固定偏移量(单位:字节)
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
|
/** 协议头长度 */
|
||||||
|
const val HEADER_LEN: Int = 4
|
||||||
|
|
||||||
|
/** 命令字段长度 */
|
||||||
|
const val CMD_LEN: Int = 1
|
||||||
|
|
||||||
|
/** 标志字段长度 */
|
||||||
|
const val FLAG_LEN: Int = 1
|
||||||
|
|
||||||
|
/** 数据体起始偏移(协议头 + 命令 + 标志) */
|
||||||
|
const val BODY_OFFSET: Int = HEADER_LEN + CMD_LEN + FLAG_LEN
|
||||||
|
|
||||||
|
/** 协议尾长度 */
|
||||||
|
const val FOOTER_LEN: Int = 2
|
||||||
|
|
||||||
|
/** 单台秤重量字节数 */
|
||||||
|
const val BYTES_PER_WEIGHT: Int = 4
|
||||||
|
|
||||||
|
/** 物联卡号字节数 */
|
||||||
|
const val SIM_CARD_NO_LEN: Int = 20
|
||||||
|
|
||||||
|
/** 固定数据体:终端ID字节数 */
|
||||||
|
const val TERMINAL_ID_LEN: Int = 15
|
||||||
|
|
||||||
|
/** 固定数据体:固件版本字节数 */
|
||||||
|
const val FIRMWARE_VERSION_LEN: Int = 5
|
||||||
|
|
||||||
|
/** 固定数据体:命令次数字节数 */
|
||||||
|
const val CMD_COUNT_LEN: Int = 1
|
||||||
|
|
||||||
|
/** 固定数据体总长度(终端ID + 固件版本 + 命令次数) */
|
||||||
|
const val FIXED_BODY_LEN: Int = TERMINAL_ID_LEN + FIRMWARE_VERSION_LEN + CMD_COUNT_LEN
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.shuwei.intelligent.shelves.serial
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单台秤的重量数据
|
||||||
|
*
|
||||||
|
* @param index 秤的编号,从1开始
|
||||||
|
* @param grams 重量,单位克,负数表示去皮/负重
|
||||||
|
*/
|
||||||
|
data class ScaleWeight(
|
||||||
|
val index: Int,
|
||||||
|
val grams: Int
|
||||||
|
) {
|
||||||
|
/** 重量转换为千克,保留3位小数 */
|
||||||
|
val kg: Double get() = grams / 1000.0
|
||||||
|
}
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
package com.shuwei.intelligent.shelves.serial
|
|
||||||
|
|
||||||
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.FOOTER
|
|
||||||
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.HEADER
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 串口数据帧解析器
|
|
||||||
* 负责将串口原始分片数据拼装为完整协议帧
|
|
||||||
* 完整帧格式:[HEADER][CMD][BODY][FOOTER]
|
|
||||||
*/
|
|
||||||
class SerialFrameParser {
|
|
||||||
|
|
||||||
/** 数据帧缓冲区,用于跨分片拼装 */
|
|
||||||
private val buffer = StringBuilder()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 解析原始串口分片数据,尝试拼装为完整帧
|
|
||||||
* @param srcData 原始串口数据片段
|
|
||||||
* @return 完整数据帧字符串;帧不完整时返回 null
|
|
||||||
*/
|
|
||||||
fun parse(srcData: String): String? {
|
|
||||||
val hasHeader = srcData.contains(HEADER)
|
|
||||||
val hasFooter = srcData.contains(FOOTER)
|
|
||||||
|
|
||||||
return when {
|
|
||||||
hasHeader && hasFooter -> parseCompleteInSingleChunk(srcData)
|
|
||||||
hasHeader && !hasFooter -> {
|
|
||||||
// 仅含帧头,等待后续分片
|
|
||||||
buffer.clear()
|
|
||||||
buffer.append(srcData.substring(srcData.lastIndexOf(HEADER))).append(",")
|
|
||||||
null
|
|
||||||
}
|
|
||||||
!hasHeader && !hasFooter -> {
|
|
||||||
// 中间分片,追加缓冲
|
|
||||||
buffer.append(srcData).append(",")
|
|
||||||
null
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
// 仅含帧尾,与缓冲区拼接完整帧
|
|
||||||
val endIndex = srcData.indexOf(FOOTER) + FOOTER.length
|
|
||||||
buffer.append(srcData.substring(0, endIndex))
|
|
||||||
val frame = buffer.toString().replace(",", "")
|
|
||||||
buffer.clear()
|
|
||||||
frame.takeIf { it.isNotEmpty() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 单个分片中同时含有帧头和帧尾的情况
|
|
||||||
*/
|
|
||||||
private fun parseCompleteInSingleChunk(srcData: String): String? {
|
|
||||||
buffer.clear()
|
|
||||||
val startIndex = srcData.lastIndexOf(HEADER)
|
|
||||||
val endIndex = srcData.indexOf(FOOTER) + FOOTER.length
|
|
||||||
|
|
||||||
return if (endIndex < startIndex) {
|
|
||||||
// 帧尾在帧头之前,说明是上一帧的残留尾部 + 新帧的头部
|
|
||||||
// 例:31314FFFFFD6DADEDB01010C
|
|
||||||
val partial = srcData.substring(startIndex)
|
|
||||||
if (partial.contains(FOOTER)) {
|
|
||||||
// 当前片段中新帧已完整
|
|
||||||
val newEnd = partial.indexOf(FOOTER) + FOOTER.length
|
|
||||||
partial.substring(0, newEnd)
|
|
||||||
} else {
|
|
||||||
// 新帧不完整,缓存等待后续分片
|
|
||||||
buffer.append(partial).append(",")
|
|
||||||
null
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 帧头在帧尾之前,正常完整帧
|
|
||||||
srcData.substring(startIndex, endIndex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 清空缓冲区 */
|
|
||||||
fun reset() {
|
|
||||||
buffer.clear()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
package com.shuwei.intelligent.shelves.serial
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import com.shuwei.intelligent.shelves.utils.hexToBytes
|
|
||||||
import com.shuwei.intelligent.shelves.utils.toHexString2
|
|
||||||
import io.github.jeadyx.jserialport.AndroidSerialPort
|
|
||||||
import io.github.jeadyx.jserialport.SerialPort
|
|
||||||
import io.github.jeadyx.jserialport.SerialPortFactory
|
|
||||||
import kotlinx.coroutines.*
|
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
|
||||||
import java.nio.charset.Charset
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
object SerialPortManager {
|
|
||||||
|
|
||||||
private const val PORT_NAME: String = "/dev/ttyS1"
|
|
||||||
private const val BAUD_RATE: Int = 115200
|
|
||||||
private var serialPort: AndroidSerialPort? = null
|
|
||||||
|
|
||||||
// private var serialPort: SerialPort? = null
|
|
||||||
private val scope = CoroutineScope(Dispatchers.IO)
|
|
||||||
|
|
||||||
// 打开串口
|
|
||||||
suspend fun open(): Boolean {
|
|
||||||
return try {
|
|
||||||
serialPort = SerialPortFactory.create().apply {
|
|
||||||
open(
|
|
||||||
portName = PORT_NAME,
|
|
||||||
baudRate = BAUD_RATE,
|
|
||||||
dataBits = 8,
|
|
||||||
stopBits = 1,
|
|
||||||
parity = SerialPort.PARITY_NONE
|
|
||||||
)
|
|
||||||
} as AndroidSerialPort?
|
|
||||||
true
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 发送数据
|
|
||||||
suspend fun send(data: String): Boolean {
|
|
||||||
return try {
|
|
||||||
serialPort?.write(data.hexToBytes())
|
|
||||||
true
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 启动接收协程
|
|
||||||
fun startReceive(callback: (String) -> Unit) {
|
|
||||||
scope.launch {
|
|
||||||
while (isActive) {
|
|
||||||
try {
|
|
||||||
val flow = serialPort?.read()
|
|
||||||
flow?.collect { buffer ->
|
|
||||||
val data = buffer.toHexString2().uppercase(Locale.ROOT)
|
|
||||||
callback(data)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
delay(50)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 关闭串口
|
|
||||||
suspend fun close() {
|
|
||||||
scope.cancel()
|
|
||||||
serialPort?.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,247 +0,0 @@
|
|||||||
package com.shuwei.intelligent.shelves.serial
|
|
||||||
|
|
||||||
import com.shuwei.intelligent.shelves.App
|
|
||||||
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.ACTIVATE_BODY_LENGTH
|
|
||||||
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.ACTIVE_MAP
|
|
||||||
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.BYTES_PER_WEIGHT
|
|
||||||
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.CMD_ACTIVATE
|
|
||||||
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.CMD_CARD_UPLOAD
|
|
||||||
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.CMD_DEVICE_STATUS
|
|
||||||
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.CMD_HEARTBEAT
|
|
||||||
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.CMD_HEARTBEAT_TERMINAL
|
|
||||||
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.CMD_LOCK_FEEDBACK
|
|
||||||
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.CMD_OPERATION_RECORD
|
|
||||||
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.CMD_POSITION_REPORT
|
|
||||||
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.CMD_TEMP_REPORT
|
|
||||||
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.CMD_TEMP_RESPONSE
|
|
||||||
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.CMD_TEMP_RESPONSE_ALT
|
|
||||||
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.CMD_TIME_SYNC
|
|
||||||
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.DEVICE_STATUS_WEIGHT_OFFSET
|
|
||||||
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.FIX_BODY_LENGTH
|
|
||||||
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.FOOTER
|
|
||||||
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.HEADER
|
|
||||||
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.HEARTBEAT_WEIGHT_OFFSET
|
|
||||||
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.TEMPERATURE_FIELD_LEN
|
|
||||||
import com.shuwei.intelligent.shelves.utils.binaryToHex
|
|
||||||
import com.shuwei.intelligent.shelves.utils.hexToBinary
|
|
||||||
import java.util.TimeZone
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 串口协议解析处理器
|
|
||||||
*
|
|
||||||
* 负责将原始协议帧字符串解析为业务事件,通过 [Callback] 接口通知调用方。
|
|
||||||
* 设计为无状态(除 terminalId/terminalVersion 外),便于单元测试。
|
|
||||||
*
|
|
||||||
* @param callback 业务回调,由 Activity/ViewModel 实现
|
|
||||||
*/
|
|
||||||
class SerialProtocolHandler(private val callback: Callback) {
|
|
||||||
|
|
||||||
/** 终端 ID(15字节/30字符),由激活指令或心跳兜底填充 */
|
|
||||||
var terminalId: String = ""
|
|
||||||
private set
|
|
||||||
|
|
||||||
/** 固件版本(5字节/10字符),由激活指令或心跳兜底填充 */
|
|
||||||
var terminalVersion: String = ""
|
|
||||||
private set
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 协议解析结果回调接口
|
|
||||||
*/
|
|
||||||
interface Callback {
|
|
||||||
/** 需要向串口发送一条指令 */
|
|
||||||
fun onSendCmd(cmd: String)
|
|
||||||
|
|
||||||
/** 解析到一组重量数据:货架编号 → 重量(克) */
|
|
||||||
fun onWeightUpdated(shelfNo: Int, weight: Int)
|
|
||||||
|
|
||||||
/** 温控信息上报,附带完整帧日志用于显示/复制 */
|
|
||||||
fun onTempReport(logMsg: String)
|
|
||||||
|
|
||||||
/** 记录日志 */
|
|
||||||
fun onLog(message: String)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理一帧完整的协议数据
|
|
||||||
*
|
|
||||||
* @param data 经 SerialFrameParser 解析后的完整帧字符串
|
|
||||||
*/
|
|
||||||
fun handle(data: String) {
|
|
||||||
if (data.isEmpty()) return
|
|
||||||
|
|
||||||
val cmd = data.substring(HEADER.length, HEADER.length + 4)
|
|
||||||
val cmdFlag = cmd.substring(0, 2)
|
|
||||||
|
|
||||||
when (cmd) {
|
|
||||||
CMD_ACTIVATE -> handleActivate(data, cmd, cmdFlag)
|
|
||||||
CMD_HEARTBEAT, CMD_HEARTBEAT_TERMINAL -> handleHeartbeat(data, cmd, cmdFlag)
|
|
||||||
CMD_LOCK_FEEDBACK -> handleLockFeedback(data, cmd, cmdFlag)
|
|
||||||
CMD_DEVICE_STATUS -> handleDeviceStatus(data, cmdFlag)
|
|
||||||
CMD_CARD_UPLOAD -> handleCardUpload(data, cmd, cmdFlag)
|
|
||||||
CMD_OPERATION_RECORD -> handleOperationRecord(data, cmd, cmdFlag)
|
|
||||||
CMD_TIME_SYNC -> handleTimeSync(data, cmd, cmdFlag)
|
|
||||||
CMD_POSITION_REPORT -> handlePositionReport(data, cmd, cmdFlag)
|
|
||||||
CMD_TEMP_REPORT -> handleTempReport(data, cmd, cmdFlag)
|
|
||||||
CMD_TEMP_RESPONSE -> callback.onLog("收到[温控信息应答]${CMD_TEMP_RESPONSE}数据:$data")
|
|
||||||
CMD_TEMP_RESPONSE_ALT -> callback.onLog("收到[温控信息应答]${CMD_TEMP_RESPONSE_ALT}数据:$data")
|
|
||||||
else -> {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 处理终端激活指令 */
|
|
||||||
private fun handleActivate(data: String, cmd: String, cmdFlag: String) {
|
|
||||||
val reqBody = data.substring(HEADER.length + cmd.length, data.length - FOOTER.length)
|
|
||||||
if (reqBody.length == ACTIVATE_BODY_LENGTH) {
|
|
||||||
terminalId = reqBody.substring(2, 32)
|
|
||||||
terminalVersion = reqBody.substring(32, 42)
|
|
||||||
val activeCode = ACTIVE_MAP[App.deviceId]?:""
|
|
||||||
callback.onLog("收到[激活]${cmdFlag}指令:${data},终端id:${terminalId},固件版本:${terminalVersion},发送激活命令:$activeCode")
|
|
||||||
callback.onSendCmd(activeCode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 处理心跳指令(0101 / 8101) */
|
|
||||||
private fun handleHeartbeat(data: String, cmd: String, cmdFlag: String) {
|
|
||||||
val respCmd = "${HEADER}01020506${terminalVersion}$FOOTER"
|
|
||||||
callback.onLog("收到[心跳]${cmdFlag}指令:${data},发送应答指令:${respCmd}")
|
|
||||||
callback.onSendCmd(respCmd)
|
|
||||||
parseHeartbeatData(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 处理开关锁反馈 */
|
|
||||||
private fun handleLockFeedback(data: String, cmd: String, cmdFlag: String) {
|
|
||||||
val respCmd = "${HEADER}020200${terminalId}${terminalVersion}$FOOTER"
|
|
||||||
callback.onLog("收到[开锁]${cmdFlag}指令:${data},发送应答指令:${respCmd}")
|
|
||||||
callback.onSendCmd(respCmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 处理获取设备状态(0302) */
|
|
||||||
private fun handleDeviceStatus(data: String, cmdFlag: String) {
|
|
||||||
callback.onLog("收到[获取设备状态]${cmdFlag}指令:${data}")
|
|
||||||
val weightStartIndex = DEVICE_STATUS_WEIGHT_OFFSET
|
|
||||||
val weightEndIndex = weightStartIndex + ProtocolConstants.shelfSensorCount() * BYTES_PER_WEIGHT
|
|
||||||
val temperatureEndIndex = weightEndIndex + TEMPERATURE_FIELD_LEN
|
|
||||||
try {
|
|
||||||
parseWeightData(start = weightStartIndex, end = weightEndIndex, data = data)
|
|
||||||
parseFixDataBody(data, temperatureEndIndex)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 处理卡号上发 */
|
|
||||||
private fun handleCardUpload(data: String, cmd: String, cmdFlag: String) {
|
|
||||||
val respCmd = "${HEADER}050203$FOOTER"
|
|
||||||
callback.onLog("收到[卡号上发]${cmdFlag}指令:${data},发送应答指令:${respCmd}")
|
|
||||||
callback.onSendCmd(respCmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 处理操作记录上传 */
|
|
||||||
private fun handleOperationRecord(data: String, cmd: String, cmdFlag: String) {
|
|
||||||
val respCmd = "${HEADER}0B0203$FOOTER"
|
|
||||||
callback.onLog("收到[操作记录上传]${cmdFlag}指令:${data},发送应答指令:${respCmd}")
|
|
||||||
callback.onSendCmd(respCmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 处理对时请求 */
|
|
||||||
private fun handleTimeSync(data: String, cmd: String, cmdFlag: String) {
|
|
||||||
val nowMs = System.currentTimeMillis()
|
|
||||||
val timeLong = nowMs / 1000 + TimeZone.getDefault().getOffset(nowMs) / 1000
|
|
||||||
val timeStr = "0000000" + String.format("%X", timeLong)
|
|
||||||
val time = timeStr.substring(timeStr.length - 8).chunked(2).reversed().joinToString("")
|
|
||||||
val respCmd = "${HEADER}0B02${time}$FOOTER"
|
|
||||||
callback.onLog("收到[对时]${cmdFlag}指令:${data},发送应答指令:${respCmd}")
|
|
||||||
callback.onSendCmd(respCmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 处理终端位置上报 */
|
|
||||||
private fun handlePositionReport(data: String, cmd: String, cmdFlag: String) {
|
|
||||||
val respCmd = "${HEADER}0E0200$FOOTER"
|
|
||||||
callback.onLog("收到[终端位置上报]${cmdFlag}指令:${data},发送应答指令:${respCmd}")
|
|
||||||
callback.onSendCmd(respCmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 处理温控信息上报 */
|
|
||||||
private fun handleTempReport(data: String, cmd: String, cmdFlag: String) {
|
|
||||||
val respCmd = "${HEADER}100200$FOOTER"
|
|
||||||
val logMsg = "收到[温控信息上报]${cmdFlag}指令:${data},发送应答指令:${respCmd}"
|
|
||||||
callback.onLog(logMsg)
|
|
||||||
callback.onSendCmd(respCmd)
|
|
||||||
callback.onTempReport(logMsg)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 解析心跳帧中的重量与固定数据体
|
|
||||||
*/
|
|
||||||
private fun parseHeartbeatData(data: String) {
|
|
||||||
runCatching {
|
|
||||||
if (data.length < ProtocolConstants.heartbeatMinLength()) return
|
|
||||||
val weightStartIndex = HEARTBEAT_WEIGHT_OFFSET
|
|
||||||
val weightEndIndex = weightStartIndex + ProtocolConstants.shelfSensorCount() * BYTES_PER_WEIGHT
|
|
||||||
parseWeightData(start = weightStartIndex, end = weightEndIndex, data = data)
|
|
||||||
val temperatureEndIndex = ProtocolConstants.heartbeatTemperatureStart() + TEMPERATURE_FIELD_LEN
|
|
||||||
parseFixDataBody(data, temperatureEndIndex)
|
|
||||||
}.onFailure { it.printStackTrace() }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 解析连续的重量数据段,每 [BYTES_PER_WEIGHT] 字符一组,依次对应货架 1..N
|
|
||||||
*
|
|
||||||
* @param start 数据段起始索引
|
|
||||||
* @param end 数据段结束索引(不含)
|
|
||||||
* @param data 完整帧字符串
|
|
||||||
*/
|
|
||||||
fun parseWeightData(start: Int, end: Int, data: String) {
|
|
||||||
var index = 1
|
|
||||||
var count = start
|
|
||||||
while (count < end) {
|
|
||||||
val weightInfo = data.substring(count, count + BYTES_PER_WEIGHT)
|
|
||||||
// 小端字节序转换:将4字节按字节倒序拼接
|
|
||||||
var weightHex = weightInfo.run {
|
|
||||||
substring(6, 8) + substring(4, 6) + substring(2, 4) + substring(0, 2)
|
|
||||||
}
|
|
||||||
var firstByteBinary = hexToBinary(weightHex.substring(0, 2))
|
|
||||||
val realWeight = if (firstByteBinary.startsWith("0")) {
|
|
||||||
// 正数:使用 toLongOrNull(16) 转换,避免超出 Int 范围
|
|
||||||
weightHex.toLongOrNull(16)?.toInt() ?: 0
|
|
||||||
} else {
|
|
||||||
// 负数:清除符号位后计算绝对值,再取反
|
|
||||||
firstByteBinary = "0${firstByteBinary.substring(1)}"
|
|
||||||
val firstByteHex = binaryToHex(firstByteBinary)
|
|
||||||
weightHex = firstByteHex + weightHex.substring(2)
|
|
||||||
//-1 * (weightHex.toLongOrNull(16)?.toInt() ?: 0)
|
|
||||||
// 直接用 Long 解析,然后减去 2^32 得到有符号值
|
|
||||||
val unsigned = weightHex.toLongOrNull(16) ?: 0L
|
|
||||||
val signed = if (unsigned >= 0x80000000L) unsigned - 0x100000000L else unsigned
|
|
||||||
signed.toInt()
|
|
||||||
}
|
|
||||||
callback.onWeightUpdated(index, realWeight)
|
|
||||||
count += BYTES_PER_WEIGHT
|
|
||||||
index++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从心跳帧或设备状态帧中补充解析 terminalId / terminalVersion。
|
|
||||||
* 激活指令(CMD_ACTIVATE)是获取这两个字段的首选来源;
|
|
||||||
* 若激活指令尚未到来,则通过此方法从心跳/设备状态帧中兜底解析。
|
|
||||||
*
|
|
||||||
* @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 == FIX_BODY_LENGTH) {
|
|
||||||
terminalId = fixDataBody.substring(0, 30)
|
|
||||||
terminalVersion = fixDataBody.substring(30, 40)
|
|
||||||
callback.onLog("从设备状态获取数据,终端id:${terminalId},固件版本:${terminalVersion}")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.shuwei.intelligent.shelves.serial
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取设备状态数据(命令 0x03,服务端发起后终端响应)
|
||||||
|
*
|
||||||
|
* @param lock1Lock 锁1锁状态:0-关 1-开
|
||||||
|
* @param lock1Door 锁1门状态:0-关 1-开
|
||||||
|
* @param lock2Lock 锁2锁状态:0-关 1-开
|
||||||
|
* @param lock2Door 锁2门状态:0-关 1-开
|
||||||
|
* @param scaleCount 秤的数量
|
||||||
|
* @param weights 各台秤的重量列表,索引从1开始
|
||||||
|
* @param temperature 温度值(原始字节,单位由温控器决定)
|
||||||
|
*/
|
||||||
|
data class StatusData(
|
||||||
|
val lock1Lock: Int,
|
||||||
|
val lock1Door: Int,
|
||||||
|
val lock2Lock: Int,
|
||||||
|
val lock2Door: Int,
|
||||||
|
val scaleCount: Int,
|
||||||
|
val weights: List<ScaleWeight>,
|
||||||
|
val temperature: Int
|
||||||
|
)
|
||||||
@@ -0,0 +1,475 @@
|
|||||||
|
package com.shuwei.intelligent.shelves.serial
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ScaleFrameParser 单元测试
|
||||||
|
*
|
||||||
|
* 测试数据来源:秤心跳数据.md 中的实际抓包数据
|
||||||
|
* 关键验证点:重量使用 BCD 解码(而非十六进制),与旧代码存在差异
|
||||||
|
*/
|
||||||
|
class ScaleFrameParserTest {
|
||||||
|
|
||||||
|
private lateinit var parser: ScaleFrameParser
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// 测试数据常量(来自 秤心跳数据.md)
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
|
/** 旧设备(12台秤)心跳帧1 */
|
||||||
|
private val OLD_HEARTBEAT_1 =
|
||||||
|
"D6DADEDB01010C0000000008000000020000800100008000000000010000800500008001000080030000800200008099180080721800800000000000000000000000000000000000000000010101015037414645333633333334333731373056322E313007FFFF"
|
||||||
|
|
||||||
|
/** 旧设备(12台秤)心跳帧2 */
|
||||||
|
private val OLD_HEARTBEAT_2 =
|
||||||
|
"D6DADEDB01010C0900000027170000122500007511000001000000090000001100000012000000430000000800008000150080591500800000000000000000000000000000000000000000010100005031323136334238423134303233303456322E313112FFFF"
|
||||||
|
|
||||||
|
/** 新设备(24台秤)心跳帧1 */
|
||||||
|
private val NEW_HEARTBEAT_1 =
|
||||||
|
"D6DADEDB0101189322000000000000053300001534000079230000611100000000000007000080030000000300000004000080010000000100008003000000180600004214008083130080741200800115008066130080501400800411008033120080631600800000000000000000000000000000000000000000010101015031463137334238423134303233303456322E313130FFFF"
|
||||||
|
|
||||||
|
/** 旧设备(12台秤)获取状态响应 */
|
||||||
|
private val OLD_STATUS =
|
||||||
|
"D6DADEDB0302010100000C1100000027170000122500007711000001000000110000001300000012000000430000001000008000150080441500805031323136334238423134303233303456322E313113FFFF"
|
||||||
|
|
||||||
|
/** 新设备(24台秤)获取状态响应 */
|
||||||
|
private val NEW_STATUS =
|
||||||
|
"D6DADEDB030201010101181422000037110000783100009033000054000080100000000100000028240000070000000100008006000080030000801806000009000000461100004014008081130080721200800115008064130080501400809810008033120080651600805031463137334238423134303233303456322E31314AFFFF"
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
parser = ScaleFrameParser()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// 一、重量 BCD 解析测试
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 协议文档示例:13 10 00 00 = 1.013KG = 1013g
|
||||||
|
* 旧代码(hex):0x1013 = 4115g ← 错误
|
||||||
|
* 新代码(BCD):"00001013".toLong() = 1013g ← 正确
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun testWeight_protocolExample_1013g() {
|
||||||
|
val bytes = byteArrayOf(0x13, 0x10, 0x00, 0x00)
|
||||||
|
val result = parser.parseWeightBytes(bytes, 0)
|
||||||
|
assertEquals("协议示例 1.013KG 解析错误", 1013, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 协议文档示例:13 01 02 00 = 20.113KG = 20113g
|
||||||
|
* 旧代码(hex):0x20113 = 131347... 越界 ← 错误
|
||||||
|
* 新代码(BCD):"00020113".toLong() = 20113g ← 正确
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun testWeight_protocolExample_20113g() {
|
||||||
|
val bytes = byteArrayOf(0x13, 0x01, 0x02, 0x00)
|
||||||
|
val result = parser.parseWeightBytes(bytes, 0)
|
||||||
|
assertEquals("协议示例 20.113KG 解析错误", 20113, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 协议文档示例:13 10 00 80 = -1.013KG = -1013g(负数,最高位为1)
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun testWeight_protocolExample_negative_1013g() {
|
||||||
|
val bytes = byteArrayOf(0x13, 0x10, 0x00, 0x80.toByte())
|
||||||
|
val result = parser.parseWeightBytes(bytes, 0)
|
||||||
|
assertEquals("协议示例 -1.013KG 解析错误", -1013, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 零重量 */
|
||||||
|
@Test
|
||||||
|
fun testWeight_zero() {
|
||||||
|
val bytes = byteArrayOf(0x00, 0x00, 0x00, 0x00)
|
||||||
|
assertEquals(0, parser.parseWeightBytes(bytes, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 实际心跳数据:08 00 00 00 = 8g(正数,小值时 BCD 与 hex 相同)
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun testWeight_actual_8g() {
|
||||||
|
val bytes = byteArrayOf(0x08, 0x00, 0x00, 0x00)
|
||||||
|
assertEquals(8, parser.parseWeightBytes(bytes, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 实际心跳数据:02 00 00 80 = -2g(负数小值)
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun testWeight_actual_negative_2g() {
|
||||||
|
val bytes = byteArrayOf(0x02, 0x00, 0x00, 0x80.toByte())
|
||||||
|
assertEquals(-2, parser.parseWeightBytes(bytes, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 实际心跳数据:99 18 00 80 = -1899g
|
||||||
|
* 旧代码(hex):-0x1899 = -6297g ← 错误(差3倍多)
|
||||||
|
* 新代码(BCD):-1899g ← 正确(约1.9KG货物)
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun testWeight_actual_negative_1899g() {
|
||||||
|
val bytes = byteArrayOf(0x99.toByte(), 0x18, 0x00, 0x80.toByte())
|
||||||
|
val result = parser.parseWeightBytes(bytes, 0)
|
||||||
|
assertEquals("心跳数据 -1899g 解析错误,旧代码会给出 -6297g", -1899, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 实际心跳数据:72 18 00 80 = -1872g
|
||||||
|
* 旧代码(hex):-0x1872 = -6258g ← 错误
|
||||||
|
* 新代码(BCD):-1872g ← 正确
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun testWeight_actual_negative_1872g() {
|
||||||
|
val bytes = byteArrayOf(0x72, 0x18, 0x00, 0x80.toByte())
|
||||||
|
assertEquals(-1872, parser.parseWeightBytes(bytes, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 实际状态数据:27 17 00 00 = 1727g
|
||||||
|
* 旧代码(hex):0x1727 = 5927g ← 错误(3.4倍)
|
||||||
|
* 新代码(BCD):1727g ← 正确(约1.7KG货物,合理)
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun testWeight_actual_1727g() {
|
||||||
|
val bytes = byteArrayOf(0x27, 0x17, 0x00, 0x00)
|
||||||
|
val result = parser.parseWeightBytes(bytes, 0)
|
||||||
|
assertEquals("状态数据 1727g 解析错误,旧代码会给出 5927g", 1727, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 实际状态数据:12 25 00 00 = 2512g
|
||||||
|
* 旧代码(hex):0x2512 = 9490g ← 错误
|
||||||
|
* 新代码(BCD):2512g ← 正确
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun testWeight_actual_2512g() {
|
||||||
|
val bytes = byteArrayOf(0x12, 0x25, 0x00, 0x00)
|
||||||
|
assertEquals(2512, parser.parseWeightBytes(bytes, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 验证 offset 参数:从字节数组中间某位置解析 */
|
||||||
|
@Test
|
||||||
|
fun testWeight_withOffset() {
|
||||||
|
val bytes = byteArrayOf(
|
||||||
|
0x00, 0x00, // 前缀,不参与解析
|
||||||
|
0x13, 0x10, 0x00, 0x00 // 1013g,从 offset=2 解析
|
||||||
|
)
|
||||||
|
assertEquals(1013, parser.parseWeightBytes(bytes, 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// 二、帧结构解析测试(parseFrame)
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testParseFrame_oldHeartbeat1_basicFields() {
|
||||||
|
val frame = parser.parseFrame(OLD_HEARTBEAT_1)
|
||||||
|
assertNotNull("帧解析不应返回 null", frame)
|
||||||
|
frame!!
|
||||||
|
assertEquals("CMD 应为 0x01(心跳)", 0x01.toByte(), frame.cmd)
|
||||||
|
assertEquals("FLAG 应为 0x01(终端发起)", 0x01.toByte(), frame.flag)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testParseFrame_oldHeartbeat1_fixedBody() {
|
||||||
|
val frame = parser.parseFrame(OLD_HEARTBEAT_1)!!
|
||||||
|
// 37414645333633333334333731373056322E313007FFFF 前固定数据体
|
||||||
|
// 终端ID: 37 41 46 45 33 36 33 33 33 34 33 37 31 37 30 → "7AFE36333437170"
|
||||||
|
assertEquals("终端ID长度应为15", 15, frame.terminalId.length)
|
||||||
|
assertEquals("终端ID解析错误", "7AFE36333437170", frame.terminalId)
|
||||||
|
// 固件版本: 56 32 2E 31 30 → "V2.10"
|
||||||
|
assertEquals("固件版本解析错误", "V2.10", frame.firmwareVersion)
|
||||||
|
assertEquals("命令次数解析错误", 7, frame.cmdCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testParseFrame_oldHeartbeat2_fixedBody() {
|
||||||
|
val frame = parser.parseFrame(OLD_HEARTBEAT_2)!!
|
||||||
|
// 终端ID: 50 31 32 31 36 33 42 38 42 31 34 30 32 33 30 → "P12163B8B140230"
|
||||||
|
assertEquals("终端ID解析错误", "12163B8B1402304", frame.terminalId)
|
||||||
|
assertEquals("固件版本解析错误", "V2.11", frame.firmwareVersion)
|
||||||
|
assertEquals("命令次数解析错误", 18, frame.cmdCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testParseFrame_newHeartbeat1_fixedBody() {
|
||||||
|
val frame = parser.parseFrame(NEW_HEARTBEAT_1)!!
|
||||||
|
// 终端ID: 50 31 46 31 37 33 42 38 42 31 34 30 32 33 30 → "P1F173B8B140230"
|
||||||
|
assertEquals("新设备终端ID解析错误", "1F173B8B1402304", frame.terminalId)
|
||||||
|
assertEquals("新设备固件版本解析错误", "V2.11", frame.firmwareVersion)
|
||||||
|
assertEquals("新设备命令次数解析错误", 48, frame.cmdCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testParseFrame_invalidData_returnsNull() {
|
||||||
|
assertNull("空字符串应返回 null", parser.parseFrame(""))
|
||||||
|
assertNull("过短数据应返回 null", parser.parseFrame("D6DADEDB0101FFFF"))
|
||||||
|
assertNull("无效协议头应返回 null", parser.parseFrame("AABBCCDD0101FFFF"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// 三、心跳数据解析测试(parseHeartbeat)
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testHeartbeat_oldDevice_scaleCount() {
|
||||||
|
val frame = parser.parseFrame(OLD_HEARTBEAT_1)!!
|
||||||
|
val data = parser.parseHeartbeat(frame.rawBody)
|
||||||
|
assertNotNull("心跳数据不应为 null", data)
|
||||||
|
assertEquals("旧设备秤数量应为12", 12, data!!.scaleCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testHeartbeat_oldDevice_weights() {
|
||||||
|
val frame = parser.parseFrame(OLD_HEARTBEAT_1)!!
|
||||||
|
val data = parser.parseHeartbeat(frame.rawBody)!!
|
||||||
|
val weights = data.weights
|
||||||
|
|
||||||
|
assertEquals("秤列表大小应与秤数量一致", 12, weights.size)
|
||||||
|
|
||||||
|
// 秤1:00000000 → 0g
|
||||||
|
assertEquals("秤1重量", 0, weights[0].grams)
|
||||||
|
assertEquals("秤1编号", 1, weights[0].index)
|
||||||
|
|
||||||
|
// 秤2:08000000 → 8g
|
||||||
|
assertEquals("秤2重量", 8, weights[1].grams)
|
||||||
|
|
||||||
|
// 秤3:02000080 → -2g(负数)
|
||||||
|
assertEquals("秤3重量(去皮负值)", -2, weights[2].grams)
|
||||||
|
|
||||||
|
// 秤4:01000080 → -1g
|
||||||
|
assertEquals("秤4重量", -1, weights[3].grams)
|
||||||
|
|
||||||
|
// 秤5:00000000 → 0g
|
||||||
|
assertEquals("秤5重量", 0, weights[4].grams)
|
||||||
|
|
||||||
|
// 秤7:05000080 → -5g
|
||||||
|
assertEquals("秤7重量", -5, weights[6].grams)
|
||||||
|
|
||||||
|
// 秤11:99180080 → -1899g(BCD;旧代码 hex 会得到 -6297g)
|
||||||
|
assertEquals("秤11重量(BCD:-1899g,hex错误值:-6297g)", -1899, weights[10].grams)
|
||||||
|
|
||||||
|
// 秤12:72180080 → -1872g(BCD;旧代码 hex 会得到 -6258g)
|
||||||
|
assertEquals("秤12重量(BCD:-1872g,hex错误值:-6258g)", -1872, weights[11].grams)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testHeartbeat_oldDevice_lockStates() {
|
||||||
|
val frame = parser.parseFrame(OLD_HEARTBEAT_1)!!
|
||||||
|
val data = parser.parseHeartbeat(frame.rawBody)!!
|
||||||
|
assertEquals("锁1门状态应为开(1)", 1, data.lock1Door)
|
||||||
|
assertEquals("锁1锁状态应为开(1)", 1, data.lock1Lock)
|
||||||
|
assertEquals("锁2门状态应为开(1)", 1, data.lock2Door)
|
||||||
|
assertEquals("锁2锁状态应为开(1)", 1, data.lock2Lock)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testHeartbeat_oldDevice_temperature() {
|
||||||
|
val frame = parser.parseFrame(OLD_HEARTBEAT_1)!!
|
||||||
|
val data = parser.parseHeartbeat(frame.rawBody)!!
|
||||||
|
// 温度字节 0x50 = 80
|
||||||
|
assertEquals("温度字节应为 80(0x50)", 80, data.temperature)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testHeartbeat_newDevice_scaleCount() {
|
||||||
|
val frame = parser.parseFrame(NEW_HEARTBEAT_1)!!
|
||||||
|
val data = parser.parseHeartbeat(frame.rawBody)!!
|
||||||
|
assertEquals("新设备秤数量应为24", 24, data.scaleCount)
|
||||||
|
assertEquals("秤列表大小应为24", 24, data.weights.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testHeartbeat_newDevice_weight1() {
|
||||||
|
val frame = parser.parseFrame(NEW_HEARTBEAT_1)!!
|
||||||
|
val data = parser.parseHeartbeat(frame.rawBody)!!
|
||||||
|
// 秤1:93220000 → big-endian "00002293" → BCD 2293g
|
||||||
|
assertEquals("新设备秤1重量(BCD:2293g,hex错误值:8851g)", 2293, data.weights[0].grams)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testHeartbeat_newDevice_weight3() {
|
||||||
|
val frame = parser.parseFrame(NEW_HEARTBEAT_1)!!
|
||||||
|
val data = parser.parseHeartbeat(frame.rawBody)!!
|
||||||
|
// 秤3:05330000 → big-endian "00003305" → BCD 3305g
|
||||||
|
assertEquals("新设备秤3重量(BCD:3305g,hex错误值:13061g)", 3305, data.weights[2].grams)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testHeartbeat_weightKg() {
|
||||||
|
val weight = ScaleWeight(index = 1, grams = 1013)
|
||||||
|
assertEquals("kg 转换错误", 1.013, weight.kg, 0.0001)
|
||||||
|
val negWeight = ScaleWeight(index = 2, grams = -1899)
|
||||||
|
assertEquals("负重量 kg 转换错误", -1.899, negWeight.kg, 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// 四、获取状态数据解析测试(parseStatus)
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testStatus_oldDevice_lockStates() {
|
||||||
|
val frame = parser.parseFrame(OLD_STATUS)!!
|
||||||
|
assertEquals("CMD 应为 0x03(获取状态)", 0x03.toByte(), frame.cmd)
|
||||||
|
val data = parser.parseStatus(frame.rawBody)!!
|
||||||
|
assertEquals("锁1锁状态", 1, data.lock1Lock)
|
||||||
|
assertEquals("锁1门状态", 1, data.lock1Door)
|
||||||
|
assertEquals("锁2锁状态", 0, data.lock2Lock)
|
||||||
|
assertEquals("锁2门状态", 0, data.lock2Door)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testStatus_oldDevice_scaleCount() {
|
||||||
|
val frame = parser.parseFrame(OLD_STATUS)!!
|
||||||
|
val data = parser.parseStatus(frame.rawBody)!!
|
||||||
|
assertEquals("旧设备状态秤数量应为12", 12, data.scaleCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testStatus_oldDevice_weights() {
|
||||||
|
val frame = parser.parseFrame(OLD_STATUS)!!
|
||||||
|
val data = parser.parseStatus(frame.rawBody)!!
|
||||||
|
val weights = data.weights
|
||||||
|
|
||||||
|
// 秤1:11000000 → big-endian "00000011" → BCD 11g(hex也是17g,差异小但仍不同)
|
||||||
|
assertEquals("状态秤1重量(BCD:11g,hex:17g)", 11, weights[0].grams)
|
||||||
|
|
||||||
|
// 秤2:27170000 → big-endian "00001727" → BCD 1727g(hex:5927g,差异巨大)
|
||||||
|
assertEquals("状态秤2重量(BCD:1727g,hex错误值:5927g)", 1727, weights[1].grams)
|
||||||
|
|
||||||
|
// 秤3:12250000 → BCD 2512g(hex:9490g)
|
||||||
|
assertEquals("状态秤3重量(BCD:2512g,hex错误值:9490g)", 2512, weights[2].grams)
|
||||||
|
|
||||||
|
// 秤4:77110000 → BCD 1177g(hex:4471g)
|
||||||
|
assertEquals("状态秤4重量(BCD:1177g,hex错误值:4471g)", 1177, weights[3].grams)
|
||||||
|
|
||||||
|
// 秤5:01000000 → 1g
|
||||||
|
assertEquals("状态秤5重量", 1, weights[4].grams)
|
||||||
|
|
||||||
|
// 秤10:10000080 → -10g(负数)
|
||||||
|
assertEquals("状态秤10重量(去皮负值)", -10, weights[9].grams)
|
||||||
|
|
||||||
|
// 秤11:00150080 → big-endian "80001500",清符号位 "00001500" → BCD -1500g
|
||||||
|
assertEquals("状态秤11重量(BCD:-1500g,hex:-5376g)", -1500, weights[10].grams)
|
||||||
|
|
||||||
|
// 秤12:44150080 → "00001544" → BCD -1544g
|
||||||
|
assertEquals("状态秤12重量(BCD:-1544g,hex:-5444g)", -1544, weights[11].grams)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testStatus_newDevice_scaleCount() {
|
||||||
|
val frame = parser.parseFrame(NEW_STATUS)!!
|
||||||
|
val data = parser.parseStatus(frame.rawBody)!!
|
||||||
|
assertEquals("新设备状态秤数量应为24", 24, data.scaleCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testStatus_newDevice_weight1() {
|
||||||
|
val frame = parser.parseFrame(NEW_STATUS)!!
|
||||||
|
val data = parser.parseStatus(frame.rawBody)!!
|
||||||
|
// 秤1:14220000 → big-endian "00002214" → BCD 2214g
|
||||||
|
assertEquals("新设备状态秤1重量(BCD:2214g)", 2214, data.weights[0].grams)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// 五、分片拼装测试(appendData)
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testAppendData_completeFrame_returnedImmediately() {
|
||||||
|
val result = parser.appendData(OLD_HEARTBEAT_1)
|
||||||
|
assertNotNull("完整帧应直接返回", result)
|
||||||
|
assertEquals("返回的帧内容应完整", OLD_HEARTBEAT_1, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testAppendData_twoFragments() {
|
||||||
|
// 将完整帧从中间切分为两段
|
||||||
|
val mid = OLD_HEARTBEAT_1.length / 2
|
||||||
|
val part1 = OLD_HEARTBEAT_1.substring(0, mid)
|
||||||
|
val part2 = OLD_HEARTBEAT_1.substring(mid)
|
||||||
|
|
||||||
|
// 第一段(含帧头,不含帧尾)→ 应返回 null
|
||||||
|
val result1 = parser.appendData(part1)
|
||||||
|
assertNull("第一分片不应返回完整帧", result1)
|
||||||
|
|
||||||
|
// 第二段(含帧尾,不含帧头)→ 应拼装完成并返回完整帧
|
||||||
|
val result2 = parser.appendData(part2)
|
||||||
|
assertNotNull("第二分片拼装后应返回完整帧", result2)
|
||||||
|
assertEquals("拼装后帧内容应与原始数据一致", OLD_HEARTBEAT_1, result2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testAppendData_threeFragments() {
|
||||||
|
// 将完整帧切成三段
|
||||||
|
val len = OLD_HEARTBEAT_1.length
|
||||||
|
val part1 = OLD_HEARTBEAT_1.substring(0, len / 3)
|
||||||
|
val part2 = OLD_HEARTBEAT_1.substring(len / 3, len * 2 / 3)
|
||||||
|
val part3 = OLD_HEARTBEAT_1.substring(len * 2 / 3)
|
||||||
|
|
||||||
|
assertNull("第1段不应返回完整帧", parser.appendData(part1))
|
||||||
|
assertNull("第2段不应返回完整帧", parser.appendData(part2))
|
||||||
|
val result = parser.appendData(part3)
|
||||||
|
assertNotNull("第3段拼装后应返回完整帧", result)
|
||||||
|
assertEquals("三段拼装结果应与原始数据一致", OLD_HEARTBEAT_1, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testAppendData_reset_clearsBuffer() {
|
||||||
|
val mid = OLD_HEARTBEAT_1.length / 2
|
||||||
|
// 先追加第一段(写入缓冲)
|
||||||
|
parser.appendData(OLD_HEARTBEAT_1.substring(0, mid))
|
||||||
|
// 重置后再追加完整帧,应正常返回
|
||||||
|
parser.reset()
|
||||||
|
val result = parser.appendData(OLD_HEARTBEAT_1)
|
||||||
|
assertNotNull("reset 后追加完整帧应正常返回", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testAppendData_newFrameAfterComplete() {
|
||||||
|
// 模拟连续两帧数据,第二帧紧跟在第一帧末尾
|
||||||
|
val twoFrames = OLD_HEARTBEAT_1 + OLD_HEARTBEAT_2
|
||||||
|
// 包含帧头和帧尾,应只返回第一帧
|
||||||
|
val result = parser.appendData(twoFrames)
|
||||||
|
assertNotNull("连续两帧中应能解析出完整帧", result)
|
||||||
|
assertTrue("应返回以协议头开头的帧", result!!.startsWith("D6DADEDB"))
|
||||||
|
assertTrue("应返回以协议尾结尾的帧", result.endsWith("FFFF"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// 六、端到端集成测试(appendData + parseFrame + parseHeartbeat)
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testEndToEnd_oldHeartbeat_fragmentedThenParse() {
|
||||||
|
val mid = OLD_HEARTBEAT_1.length / 2
|
||||||
|
parser.appendData(OLD_HEARTBEAT_1.substring(0, mid))
|
||||||
|
val frameHex = parser.appendData(OLD_HEARTBEAT_1.substring(mid))!!
|
||||||
|
|
||||||
|
val frame = parser.parseFrame(frameHex)!!
|
||||||
|
val data = parser.parseHeartbeat(frame.rawBody)!!
|
||||||
|
|
||||||
|
assertEquals(0x01.toByte(), frame.cmd)
|
||||||
|
assertEquals(12, data.scaleCount)
|
||||||
|
assertEquals(8, data.weights[1].grams) // 秤2: 8g
|
||||||
|
assertEquals(-1899, data.weights[10].grams) // 秤11: -1899g
|
||||||
|
assertEquals("7AFE36333437170", frame.terminalId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testEndToEnd_newHeartbeat_parseComplete() {
|
||||||
|
val frameHex = parser.appendData(NEW_HEARTBEAT_1)!!
|
||||||
|
val frame = parser.parseFrame(frameHex)!!
|
||||||
|
val data = parser.parseHeartbeat(frame.rawBody)!!
|
||||||
|
|
||||||
|
assertEquals(24, data.scaleCount)
|
||||||
|
assertEquals(2293, data.weights[0].grams) // 秤1: 2293g
|
||||||
|
assertEquals("1F173B8B1402304", frame.terminalId)
|
||||||
|
assertEquals("V2.11", frame.firmwareVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
协议头 :4 字节
|
||||||
|
命令 :1 字节
|
||||||
|
标志 :1 字节
|
||||||
|
数据体 :不同命令不固定,详细见下方
|
||||||
|
协议尾 :2字节
|
||||||
|
|
||||||
|
|
||||||
|
协议头 :0xD6 0xDA 0xDE 0xDB
|
||||||
|
命令:
|
||||||
|
◼ 0x00:终端激活(终端发起)
|
||||||
|
◼ 0x01:心跳(终端发起)
|
||||||
|
◼ 0x81:心跳(终端发起,当终端发送此命令时需要服务端存储串口屏显示信息)
|
||||||
|
◼ 0x02:开锁(服务端发起)
|
||||||
|
◼ 0x03:获取状态(服务端发起)
|
||||||
|
◼ 0x04:秤置零(服务端发起)
|
||||||
|
◼ 0x06:秤标定(服务端发起)
|
||||||
|
◼ 0x05:卡号上发(终端发起)
|
||||||
|
◼ 0x07:价签设置(服务端发起)
|
||||||
|
◼ 0x08:串口屏(服务端发起)
|
||||||
|
◼ 0x09:语音播报(服务端发起)
|
||||||
|
◼ 0x0A:价签(第二块串口屏/透传)设置(服务端发起)
|
||||||
|
◼ 0x0B:操作记录上传(终端发起)
|
||||||
|
◼ 0x0C:对时(终端发起)
|
||||||
|
◼ 0x0E:终端位置上报(终端发起)
|
||||||
|
◼ 0x0F:温控器控制(服务端发起)
|
||||||
|
◼ 0x10:温控信息上报(终端发起)
|
||||||
|
◼ 0x11:临时参数设置(服务端发起)
|
||||||
|
|
||||||
|
固定数据体 :(终端ID :15 字节,固件版本 :5字节,命令次数 :1字节)
|
||||||
|
|
||||||
|
0x01心跳终端发起数据体内容格式:
|
||||||
|
秤数量 :1 字节
|
||||||
|
N个秤重量数据 :N x 4字节
|
||||||
|
物联卡号 :20 字节
|
||||||
|
锁1门状态 :1 字节
|
||||||
|
锁1锁状态 :1 字节
|
||||||
|
锁2门状态 :1 字节
|
||||||
|
锁2锁状态 :1 字节
|
||||||
|
温度 :1 字节
|
||||||
|
固定数据体 :(终端ID :15 字节,固件版本 :5字节,命令次数 :1字节)
|
||||||
|
|
||||||
|
|
||||||
|
0x03获取状态服务端响应数据体内容格式:
|
||||||
|
锁1锁状态 :1 字节
|
||||||
|
锁1门状态 :1 字节
|
||||||
|
锁2锁状态 :1 字节
|
||||||
|
锁2门状态 :1 字节
|
||||||
|
秤数量 :1 字节
|
||||||
|
N个秤重量数据 :N x 4字节
|
||||||
|
温度 :1 字节
|
||||||
|
固定数据体 :(终端ID :15 字节,固件版本 :5字节,命令次数 :1字节)
|
||||||
|
|
||||||
|
示例数据见下方----------------------------
|
||||||
|
|
||||||
|
旧设备10台秤心跳数据:
|
||||||
|
D6DADEDB01010C0000000008000000020000800100008000000000010000800500008001000080030000800200008099180080721800800000000000000000000000000000000000000000010101015037414645333633333334333731373056322E313007FFFF
|
||||||
|
|
||||||
|
D6DADEDB01010C0900000027170000122500007511000001000000090000001100000012000000430000000800008000150080591500800000000000000000000000000000000000000000010100005031323136334238423134303233303456322E313112FFFF
|
||||||
|
|
||||||
|
新设备15台秤心跳数据:
|
||||||
|
D6DADEDB0101189322000000000000053300001534000079230000611100000000000007000080030000000300000004000080010000000100008003000000180600004214008083130080741200800115008066130080501400800411008033120080631600800000000000000000000000000000000000000000010101015031463137334238423134303233303456322E313130FFFF
|
||||||
|
|
||||||
|
D6DADEDB0101181422000037110000823100009233000054000080100000000100000030240000050000000300008002000080050000802006000007000000441100004014008081130080721200800315008064130080521400809610008033120080631600800000000000000000000000000000000000000000010101015031463137334238423134303233303456322E313147FFFF
|
||||||
|
|
||||||
|
D6DADEDB0101180635000075190000842800000100008014000080060000800100008004000080050000801500008007000000080000800700008003000000050000008118008054180080231800801018008059170080221800809418008075320080761900800000000000000000000000000000000000000000010101015033453137334238423134303233303456322E313139FFFF
|
||||||
|
|
||||||
|
|
||||||
|
旧设备10台秤获取状态数据:
|
||||||
|
D6DADEDB0302010100000C1100000027170000122500007711000001000000110000001300000012000000430000001000008000150080441500805031323136334238423134303233303456322E313113FFFF
|
||||||
|
|
||||||
|
新设备15台秤获取状态数据:
|
||||||
|
D6DADEDB030201010101180635000077190000842800000100008014000080040000800100008004000080050000801500008007000000080000800900008005000000030000008118008054180080231800801018008059170080221800809418008079320080761900805033453137334238423134303233303456322E31313CFFFF
|
||||||
|
|
||||||
|
D6DADEDB030201010101181422000037110000783100009033000054000080100000000100000028240000070000000100008006000080030000801806000009000000461100004014008081130080721200800115008064130080501400809810008033120080651600805031463137334238423134303233303456322E31314AFFFF
|
||||||
Binary file not shown.
Reference in New Issue
Block a user