Author SHA1 Message Date
mazengfei b5a9522fa7 新增2列设备 2026-05-26 16:58:59 +08:00
lvmengandClaude Sonnet 4.6 967d06ce1e docs(serial): 更新 ScaleManager 类注释
- 同步典型用法为当前链式 start() 写法
- 职责说明补充 Channel 发送队列描述
- release() 说明补充"清空队列"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 15:47:56 +08:00
lvmengandClaude Sonnet 4.6 d5c6ec92d4 refactor(serial): sendCmd 改用 Channel 保证串口写入顺序
- 新增 sendChannel(无界 Channel)作为发送队列
- openPort() 启动单一消费者协程顺序消费队列,避免并发写串口
- sendCmd() 改为 trySend() 入队,不再每次创建新协程
- release() 关闭 Channel,确保消费者协程正常退出

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 15:02:15 +08:00
lvmengandClaude Sonnet 4.6 7b28eb03d6 refactor(serial): 清理废弃类并复用现有工具函数
- 删除 SerialProtocolHandler(旧版协议解析器,已无任何引用)
- 删除 SerialFrameParser(仅被 SerialProtocolHandler 使用,随其废弃)
- ScaleFrameParser.parseFrame 改用项目已有的 String.hexToBytes(),移除重复的私有 hexToByteArray()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 15:00:08 +08:00
lvmengandClaude Sonnet 4.6 4d545ceb46 refactor(serial): 将 SerialPortManager 合并到 ScaleManager
- 删除 SerialPortManager,串口生命周期(open/startReceive/release)内嵌到 ScaleManager
- ScaleManager 改为 object 单例,去除 .instance 调用
- init() 简化为只接收 activateCode,去除 deviceId + activateMap 查表逻辑
- 新增 start(preHook) 方法合并 open + startReceive,支持链式调用到末尾
- BaseActivity.sendCmd 改为直接调用 ScaleManager.sendCmd

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 14:59:19 +08:00
mazengfeiandClaude Sonnet 4.6 0eede1059c 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>
2026-05-21 13:54:02 +08:00
17 changed files with 1715 additions and 489 deletions
@@ -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 终端ID15字节),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 继电器11=开 0=关 其他值=不动作
* @param relay2 继电器21=开 0=关 其他值=不动作
* @param relay3 继电器31=开 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)
// 秤100000000 → 0g
assertEquals("秤1重量", 0, weights[0].grams)
assertEquals("秤1编号", 1, weights[0].index)
// 秤208000000 → 8g
assertEquals("秤2重量", 8, weights[1].grams)
// 秤302000080 → -2g(负数)
assertEquals("秤3重量(去皮负值)", -2, weights[2].grams)
// 秤401000080 → -1g
assertEquals("秤4重量", -1, weights[3].grams)
// 秤500000000 → 0g
assertEquals("秤5重量", 0, weights[4].grams)
// 秤705000080 → -5g
assertEquals("秤7重量", -5, weights[6].grams)
// 秤1199180080 → -1899gBCD;旧代码 hex 会得到 -6297g
assertEquals("秤11重量(BCD:-1899ghex错误值:-6297g", -1899, weights[10].grams)
// 秤1272180080 → -1872gBCD;旧代码 hex 会得到 -6258g
assertEquals("秤12重量(BCD:-1872ghex错误值:-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("温度字节应为 800x50", 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)!!
// 秤193220000 → big-endian "00002293" → BCD 2293g
assertEquals("新设备秤1重量(BCD:2293ghex错误值:8851g)", 2293, data.weights[0].grams)
}
@Test
fun testHeartbeat_newDevice_weight3() {
val frame = parser.parseFrame(NEW_HEARTBEAT_1)!!
val data = parser.parseHeartbeat(frame.rawBody)!!
// 秤305330000 → big-endian "00003305" → BCD 3305g
assertEquals("新设备秤3重量(BCD:3305ghex错误值: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
// 秤111000000 → big-endian "00000011" → BCD 11ghex也是17g,差异小但仍不同)
assertEquals("状态秤1重量(BCD:11ghex:17g)", 11, weights[0].grams)
// 秤227170000 → big-endian "00001727" → BCD 1727ghex:5927g,差异巨大)
assertEquals("状态秤2重量(BCD:1727ghex错误值:5927g)", 1727, weights[1].grams)
// 秤312250000 → BCD 2512ghex:9490g
assertEquals("状态秤3重量(BCD:2512ghex错误值:9490g)", 2512, weights[2].grams)
// 秤477110000 → BCD 1177ghex:4471g
assertEquals("状态秤4重量(BCD:1177ghex错误值:4471g)", 1177, weights[3].grams)
// 秤501000000 → 1g
assertEquals("状态秤5重量", 1, weights[4].grams)
// 秤1010000080 → -10g(负数)
assertEquals("状态秤10重量(去皮负值)", -10, weights[9].grams)
// 秤1100150080 → big-endian "80001500",清符号位 "00001500" → BCD -1500g
assertEquals("状态秤11重量(BCD:-1500ghex:-5376g)", -1500, weights[10].grams)
// 秤1244150080 → "00001544" → BCD -1544g
assertEquals("状态秤12重量(BCD:-1544ghex:-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)!!
// 秤114220000 → 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)
}
}
+74
View File
@@ -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.