diff --git a/app/src/main/java/com/shuwei/intelligent/shelves/activity/HomeActivity.kt b/app/src/main/java/com/shuwei/intelligent/shelves/activity/HomeActivity.kt index 42d9670..0a042cd 100644 --- a/app/src/main/java/com/shuwei/intelligent/shelves/activity/HomeActivity.kt +++ b/app/src/main/java/com/shuwei/intelligent/shelves/activity/HomeActivity.kt @@ -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) { - if (shelfNo in 1..list.size) { - list.firstOrNull { it.deviceNo == shelfNo }?.let { model -> - model.weight = weight.toDouble() - val pos = list.indexOf(model) - shelfAdapter.notifyItemChanged(pos) - log("getWeightInfo: deviceNo=${model.deviceNo},realWeight=$weight") - EventBus.getDefault().post(SendWeightEvent(model.deviceNo, weight)) + 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.grams.toDouble() + val pos = list.indexOf(model) + shelfAdapter.notifyItemChanged(pos) + 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]) diff --git a/app/src/main/java/com/shuwei/intelligent/shelves/activity/ShelfActivity.kt b/app/src/main/java/com/shuwei/intelligent/shelves/activity/ShelfActivity.kt index d69789c..778ab0d 100644 --- a/app/src/main/java/com/shuwei/intelligent/shelves/activity/ShelfActivity.kt +++ b/app/src/main/java/com/shuwei/intelligent/shelves/activity/ShelfActivity.kt @@ -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) diff --git a/app/src/main/java/com/shuwei/intelligent/shelves/base/BaseActivity.kt b/app/src/main/java/com/shuwei/intelligent/shelves/base/BaseActivity.kt index 2d81626..5b624f5 100644 --- a/app/src/main/java/com/shuwei/intelligent/shelves/base/BaseActivity.kt +++ b/app/src/main/java/com/shuwei/intelligent/shelves/base/BaseActivity.kt @@ -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}") diff --git a/app/src/main/java/com/shuwei/intelligent/shelves/serial/HeartbeatData.kt b/app/src/main/java/com/shuwei/intelligent/shelves/serial/HeartbeatData.kt new file mode 100644 index 0000000..5b00051 --- /dev/null +++ b/app/src/main/java/com/shuwei/intelligent/shelves/serial/HeartbeatData.kt @@ -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, + val simCardNo: String, + val lock1Door: Int, + val lock1Lock: Int, + val lock2Door: Int, + val lock2Lock: Int, + val temperature: Int +) diff --git a/app/src/main/java/com/shuwei/intelligent/shelves/serial/ScaleFrame.kt b/app/src/main/java/com/shuwei/intelligent/shelves/serial/ScaleFrame.kt new file mode 100644 index 0000000..cc00a4c --- /dev/null +++ b/app/src/main/java/com/shuwei/intelligent/shelves/serial/ScaleFrame.kt @@ -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 + } +} diff --git a/app/src/main/java/com/shuwei/intelligent/shelves/serial/ScaleFrameParser.kt b/app/src/main/java/com/shuwei/intelligent/shelves/serial/ScaleFrameParser.kt new file mode 100644 index 0000000..1849edc --- /dev/null +++ b/app/src/main/java/com/shuwei/intelligent/shelves/serial/ScaleFrameParser.kt @@ -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() + } + } +} diff --git a/app/src/main/java/com/shuwei/intelligent/shelves/serial/ScaleManager.kt b/app/src/main/java/com/shuwei/intelligent/shelves/serial/ScaleManager.kt new file mode 100644 index 0000000..d64f3b8 --- /dev/null +++ b/app/src/main/java/com/shuwei/intelligent/shelves/serial/ScaleManager.kt @@ -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 = 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 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 = 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) } +} diff --git a/app/src/main/java/com/shuwei/intelligent/shelves/serial/ScaleProtocolConstants.kt b/app/src/main/java/com/shuwei/intelligent/shelves/serial/ScaleProtocolConstants.kt new file mode 100644 index 0000000..f32d3f4 --- /dev/null +++ b/app/src/main/java/com/shuwei/intelligent/shelves/serial/ScaleProtocolConstants.kt @@ -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 +} diff --git a/app/src/main/java/com/shuwei/intelligent/shelves/serial/ScaleWeight.kt b/app/src/main/java/com/shuwei/intelligent/shelves/serial/ScaleWeight.kt new file mode 100644 index 0000000..20bdccb --- /dev/null +++ b/app/src/main/java/com/shuwei/intelligent/shelves/serial/ScaleWeight.kt @@ -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 +} diff --git a/app/src/main/java/com/shuwei/intelligent/shelves/serial/StatusData.kt b/app/src/main/java/com/shuwei/intelligent/shelves/serial/StatusData.kt new file mode 100644 index 0000000..7818c48 --- /dev/null +++ b/app/src/main/java/com/shuwei/intelligent/shelves/serial/StatusData.kt @@ -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, + val temperature: Int +) diff --git a/app/src/test/java/com/shuwei/intelligent/shelves/serial/ScaleFrameParserTest.kt b/app/src/test/java/com/shuwei/intelligent/shelves/serial/ScaleFrameParserTest.kt new file mode 100644 index 0000000..c57f8f1 --- /dev/null +++ b/app/src/test/java/com/shuwei/intelligent/shelves/serial/ScaleFrameParserTest.kt @@ -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) + } +} diff --git a/秤心跳数据.md b/秤心跳数据.md new file mode 100644 index 0000000..7d9943a --- /dev/null +++ b/秤心跳数据.md @@ -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 \ No newline at end of file diff --git a/称重控制板接口协议.pdf b/称重控制板接口协议.pdf new file mode 100644 index 0000000..1330649 Binary files /dev/null and b/称重控制板接口协议.pdf differ