feat(serial): 封装 ScaleManager 替换现有秤功能逻辑
新增 SDK 核心类: - ScaleProtocolConstants:协议常量(命令字节、帧偏移) - ScaleFrameParser:分片拼装、帧解析、BCD重量解析(修复旧代码 hex 误用问题) - ScaleFrame / ScaleWeight / HeartbeatData / StatusData:数据模型 - ScaleManager:单例入口,支持链式回调注册、自动应答、指令构建 - ScaleFrameParserTest:基于实际抓包数据的单元测试 业务层替换: - HomeActivity:移除 SerialProtocolHandler/SerialFrameParser,改用 ScaleManager 回调 - BaseActivity:sendCmd() 保持调用 SerialPortManager,与 ScaleManager.sendAction 独立 - ShelfActivity:秤置零、获取状态指令改用 ScaleManager 构建方法 Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
8145eb588b
commit
0eede1059c
@@ -29,15 +29,11 @@ import com.shuwei.intelligent.shelves.net.Loading
|
||||
import com.shuwei.intelligent.shelves.net.NetViewModel
|
||||
import com.shuwei.intelligent.shelves.net.RespData
|
||||
import com.shuwei.intelligent.shelves.net.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.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.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.IntervalExecutor
|
||||
import com.shuwei.intelligent.shelves.utils.ext.copyTextToClipboard
|
||||
@@ -80,41 +76,35 @@ class HomeActivity : BaseActivity() {
|
||||
private val viewModel: NetViewModel by viewModels()
|
||||
|
||||
// 串口协议解析器,通过回调通知本 Activity
|
||||
private val serialProtocolHandler =
|
||||
SerialProtocolHandler(object : SerialProtocolHandler.Callback {
|
||||
override fun onSendCmd(cmd: String) = sendCmd(cmd)
|
||||
|
||||
override fun onWeightUpdated(shelfNo: Int, weight: Int) {
|
||||
private fun initScaleManager() {
|
||||
ScaleManager.instance
|
||||
.init(
|
||||
sendAction = { cmd ->
|
||||
// ScaleManager 的自动应答(心跳/对时等)直接写串口,不经过 BaseActivity.sendCmd
|
||||
lifecycleScope.launch { SerialPortManager.send(cmd) }
|
||||
},
|
||||
deviceId = App.deviceId ?: "",
|
||||
activateMap = ProtocolConstants.ACTIVE_MAP
|
||||
)
|
||||
.onHeartbeat { _, data ->
|
||||
data.weights.forEach { weight ->
|
||||
val shelfNo = weight.index
|
||||
if (shelfNo in 1..list.size) {
|
||||
list.firstOrNull { it.deviceNo == shelfNo }?.let { model ->
|
||||
model.weight = weight.toDouble()
|
||||
model.weight = weight.grams.toDouble()
|
||||
val pos = list.indexOf(model)
|
||||
shelfAdapter.notifyItemChanged(pos)
|
||||
log("getWeightInfo: deviceNo=${model.deviceNo},realWeight=$weight")
|
||||
EventBus.getDefault().post(SendWeightEvent(model.deviceNo, weight))
|
||||
log("getWeightInfo: deviceNo=${model.deviceNo},realWeight=${weight.grams}")
|
||||
EventBus.getDefault().post(SendWeightEvent(model.deviceNo, weight.grams))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTempReport(logMsg: String) {
|
||||
}
|
||||
.onTempReport { _ ->
|
||||
updateLeftStatus(deviceName)
|
||||
log(logMsg)
|
||||
// if (tipDialog == null) {
|
||||
// tipDialog = CommonDialog(this@HomeActivity).apply {
|
||||
// dialogTitle = "温馨提示"
|
||||
// confirmBlock = { logMsg.copyTextToClipboard(this@HomeActivity) }
|
||||
// }
|
||||
// }
|
||||
// if (tipDialog?.isShowing == false) {
|
||||
// tipDialog?.dialogContent = logMsg
|
||||
// tipDialog?.show()
|
||||
// }
|
||||
}
|
||||
|
||||
override fun onLog(message: String) = log(message)
|
||||
})
|
||||
|
||||
private val serialFrameParser = SerialFrameParser()
|
||||
.onLog { message -> log(message) }
|
||||
}
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -129,6 +119,9 @@ class HomeActivity : BaseActivity() {
|
||||
updateLeftStatus("")
|
||||
initRecyclerView()
|
||||
|
||||
// 初始化 ScaleManager(串口初始化前注册回调)
|
||||
initScaleManager()
|
||||
|
||||
// 串口初始化,独立协程,不阻塞网络状态监听
|
||||
lifecycleScope.launch {
|
||||
val openState = SerialPortManager.open()
|
||||
@@ -262,8 +255,7 @@ class HomeActivity : BaseActivity() {
|
||||
// 重置无数据计时,并关闭已显示的超时提示弹窗
|
||||
lastDataReceivedTime = System.currentTimeMillis()
|
||||
noDataWarningDialog?.takeIf { it.isShowing }?.dismiss()
|
||||
val frame = serialFrameParser.parse(srcData) ?: return
|
||||
serialProtocolHandler.handle(frame)
|
||||
ScaleManager.instance.onDataReceived(srcData)
|
||||
}
|
||||
|
||||
private var tipDialog: CommonDialog? = null
|
||||
@@ -318,6 +310,7 @@ class HomeActivity : BaseActivity() {
|
||||
noDataWarningDialog = null
|
||||
|
||||
runBlocking { SerialPortManager.close() }
|
||||
ScaleManager.instance.release()
|
||||
saveTaskJob?.cancel()
|
||||
overdueTaskJob?.cancel()
|
||||
EventBus.getDefault().unregister(this)
|
||||
@@ -340,10 +333,10 @@ class HomeActivity : BaseActivity() {
|
||||
lifecycleScope.launch {
|
||||
if (isNeedOpenLock()) {
|
||||
val openCmd = if (list[position].deviceNo in 1..5)
|
||||
LEFT_SHELF_OPEN_CMD
|
||||
ScaleManager.instance.buildOpenLock1Cmd()
|
||||
else
|
||||
RIGHT_SHELF_OPEN_CMD
|
||||
SerialPortManager.send(openCmd)
|
||||
ScaleManager.instance.buildOpenLock2Cmd()
|
||||
ScaleManager.instance.sendCmd(openCmd)
|
||||
}
|
||||
launch(Intent(this@HomeActivity, ShelfActivity::class.java).also {
|
||||
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.shuwei.intelligent.shelves.App
|
||||
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.base.BaseActivity
|
||||
import com.shuwei.intelligent.shelves.databinding.ActivityShelfBinding
|
||||
@@ -146,7 +146,7 @@ class ShelfActivity : BaseActivity() {
|
||||
//binding.tvFoodWeight.text = "0克"
|
||||
EventBus.getDefault().post(ClearShelfEvent(shelfModel!!.deviceNo, tempWeight))
|
||||
}
|
||||
sendCmd(ProtocolConstants.DEVICE_INFO_CMD)
|
||||
sendCmd(ScaleManager.instance.buildGetStatusCmd())
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
viewModel.getGoodsListUiState.collect { state ->
|
||||
@@ -359,9 +359,7 @@ class ShelfActivity : BaseActivity() {
|
||||
private fun clearZero() {
|
||||
lifecycleScope.launch {
|
||||
val shelfNo = shelfModel?.deviceNo ?: 0
|
||||
val hex = shelfNo.toString(16).padStart(2, '0').uppercase()
|
||||
Log.d(TAG, "onCreate: hex=$hex")
|
||||
val zeroClearingCmd = "${ProtocolConstants.HEADER}0401${hex}${ProtocolConstants.FOOTER}"
|
||||
val zeroClearingCmd = ScaleManager.instance.buildScaleZeroCmd(shelfNo)
|
||||
log("编号${shelfNo}货架执行清零指令:${zeroClearingCmd}")
|
||||
sendCmd(zeroClearingCmd)
|
||||
// sendCmd(
|
||||
@@ -371,7 +369,7 @@ class ShelfActivity : BaseActivity() {
|
||||
realWeight = 0
|
||||
// binding.tvFoodWeight.text = "${realWeight}克"
|
||||
|
||||
sendCmd(ProtocolConstants.DEVICE_INFO_CMD)
|
||||
sendCmd(ScaleManager.instance.buildGetStatusCmd())
|
||||
startTime = System.currentTimeMillis()
|
||||
Loading.show(this@ShelfActivity)
|
||||
window.decorView.postDelayed({ Loading.dismiss() }, 10000)
|
||||
|
||||
@@ -214,7 +214,7 @@ open class BaseActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
|
||||
fun sendCmd(cmd:String) {
|
||||
fun sendCmd(cmd: String) {
|
||||
lifecycleScope.launch {
|
||||
val result = SerialPortManager.send(cmd)
|
||||
log("发送命令${cmd}结果:${result}")
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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,294 @@
|
||||
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
|
||||
|
||||
/**
|
||||
* 协议帧组装与解析器
|
||||
*
|
||||
* 职责一:将串口原始分片十六进制字符串拼装为完整协议帧
|
||||
* 职责二:将完整帧解析为 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.hexToByteArray()
|
||||
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()
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// 私有工具
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 十六进制字符串转字节数组
|
||||
* 例:"D6DADEDB" → [0xD6, 0xDA, 0xDE, 0xDB]
|
||||
*/
|
||||
private fun String.hexToByteArray(): ByteArray {
|
||||
require(length % 2 == 0) { "十六进制字符串长度必须为偶数" }
|
||||
return ByteArray(length / 2) { i ->
|
||||
substring(i * 2, i * 2 + 2).toInt(16).toByte()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
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 java.util.TimeZone
|
||||
|
||||
/**
|
||||
* 称重控制板协议管理器(SDK 入口)
|
||||
*
|
||||
* 职责:
|
||||
* - 接收串口原始分片数据,自动拼装帧并分发到对应回调
|
||||
* - 内置各命令的自动应答逻辑(心跳/对时/卡号等)
|
||||
* - 提供链式回调注册、常用指令构建、资源释放
|
||||
*
|
||||
* 设计原则:
|
||||
* - 不依赖具体串口实现,通过 init(sendAction) 注入发送函数
|
||||
* - 不依赖任何业务类,可直接复制到其他项目使用
|
||||
* - 非线程安全,建议在同一线程(如串口IO线程或主线程)中使用
|
||||
*
|
||||
* 典型用法:
|
||||
* ```kotlin
|
||||
* // 初始化(Activity/Application中)
|
||||
* ScaleManager.instance
|
||||
* .init { hexStr -> scope.launch { SerialPortManager.send(hexStr) } }
|
||||
* .onHeartbeat { frame, data ->
|
||||
* data.weights.forEach { Log.d("Scale", "秤${it.index}: ${it.grams}g") }
|
||||
* }
|
||||
* .onStatus { frame, data ->
|
||||
* Log.d("Scale", "锁1状态: ${data.lock1Lock}")
|
||||
* }
|
||||
* .onLog { msg -> Log.d("Scale", msg) }
|
||||
*
|
||||
* // 在串口数据回调中调用
|
||||
* ScaleManager.instance.onDataReceived(hexData)
|
||||
*
|
||||
* // 主动发送指令
|
||||
* ScaleManager.instance.sendCmd(ScaleManager.instance.buildGetStatusCmd())
|
||||
*
|
||||
* // 页面销毁时释放回调(不影响其他页面注册的回调,若需完全释放则调用 release())
|
||||
* ```
|
||||
*/
|
||||
class ScaleManager {
|
||||
|
||||
companion object {
|
||||
/** 默认单例,适用于单设备场景;多设备场景请自行实例化 */
|
||||
@JvmStatic
|
||||
val instance: ScaleManager by lazy { ScaleManager() }
|
||||
}
|
||||
|
||||
private val parser = ScaleFrameParser()
|
||||
private var sendAction: ((String) -> Unit)? = null
|
||||
|
||||
/** 激活码映射表:key=deviceId,value=完整激活应答帧十六进制字符串 */
|
||||
private var activateMap: Map<String, String> = emptyMap()
|
||||
|
||||
/** 当前设备ID,用于激活码查表 */
|
||||
private var currentDeviceId: 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 sendAction 发送十六进制字符串指令的函数,在该函数内完成实际串口写入
|
||||
* @param deviceId 当前设备ID,用于激活码查表
|
||||
* @param activateMap 激活码映射表:key=deviceId,value=完整激活应答帧十六进制字符串;
|
||||
* 传入后终端激活请求将由 SDK 内部自动应答,无需再注册 onActivate 回调
|
||||
* @return this,支持链式调用
|
||||
*/
|
||||
fun init(
|
||||
sendAction: (String) -> Unit,
|
||||
deviceId: String = "",
|
||||
activateMap: Map<String, String> = emptyMap()
|
||||
): ScaleManager {
|
||||
this.sendAction = sendAction
|
||||
this.currentDeviceId = deviceId
|
||||
this.activateMap = activateMap
|
||||
return this
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// 数据接收入口
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 接收串口原始数据(在串口回调中调用此方法)
|
||||
*
|
||||
* 内部自动完成分片拼装 → 帧解析 → 命令分发 → 自动应答
|
||||
*
|
||||
* @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
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// 发送指令
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 发送十六进制字符串指令(需先调用 init() 注入发送函数)
|
||||
*
|
||||
* @param hexStr 完整帧十六进制字符串,含协议头尾
|
||||
*/
|
||||
fun sendCmd(hexStr: String) {
|
||||
val action = sendAction ?: run {
|
||||
log("警告:ScaleManager 尚未初始化,请先调用 init(sendAction)")
|
||||
return
|
||||
}
|
||||
action(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 销毁或不再使用时调用
|
||||
*/
|
||||
fun release() {
|
||||
heartbeatListeners.clear()
|
||||
statusListeners.clear()
|
||||
lockListeners.clear()
|
||||
cardListeners.clear()
|
||||
operationListeners.clear()
|
||||
timeSyncListeners.clear()
|
||||
positionListeners.clear()
|
||||
tempReportListeners.clear()
|
||||
activateListeners.clear()
|
||||
rawListeners.clear()
|
||||
logListeners.clear()
|
||||
sendAction = null
|
||||
activateMap = emptyMap()
|
||||
currentDeviceId = ""
|
||||
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 = activateMap[currentDeviceId]
|
||||
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
|
||||
}
|
||||
@@ -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