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>
This commit is contained in:
2026-05-21 14:59:19 +08:00
co-authored by Claude Sonnet 4.6
parent 0eede1059c
commit 4d545ceb46
5 changed files with 155 additions and 192 deletions
@@ -33,7 +33,6 @@ import com.shuwei.intelligent.shelves.serial.ProtocolConstants
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.C_TEMP_CMD
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.START_TEMP_CTRL_CMD
import com.shuwei.intelligent.shelves.serial.ScaleManager
import com.shuwei.intelligent.shelves.serial.SerialPortManager
import com.shuwei.intelligent.shelves.utils.GridLayoutTool
import com.shuwei.intelligent.shelves.utils.IntervalExecutor
import com.shuwei.intelligent.shelves.utils.ext.copyTextToClipboard
@@ -77,33 +76,34 @@ class HomeActivity : BaseActivity() {
// 串口协议解析器,通过回调通知本 Activity
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))
lifecycleScope.launch {
val ok = ScaleManager
.init(activateCode = ProtocolConstants.ACTIVE_MAP[App.deviceId] ?: "")
.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))
}
}
}
}
}
.onTempReport { _ ->
updateLeftStatus(deviceName)
}
.onLog { message -> log(message) }
.onTempReport { _ ->
updateLeftStatus(deviceName)
}
.onLog { message -> log(message) }
.start { data ->
lastDataReceivedTime = System.currentTimeMillis()
noDataWarningDialog?.takeIf { it.isShowing }?.dismiss()
log("receiveSerialPortData: $data")
}
log("onCreate: openState=$ok")
}
}
@SuppressLint("NotifyDataSetChanged")
@@ -119,28 +119,9 @@ class HomeActivity : BaseActivity() {
updateLeftStatus("")
initRecyclerView()
// 初始化 ScaleManager(串口初始化前注册回调)
// 初始化 ScaleManager:注册回调、打开串口、启动接收
initScaleManager()
// 串口初始化,独立协程,不阻塞网络状态监听
lifecycleScope.launch {
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 {
repeatOnLifecycle(Lifecycle.State.STARTED) {
@@ -250,14 +231,6 @@ class HomeActivity : BaseActivity() {
loadEmptyView()
}
private fun receiveSerialPortData(srcData: String) {
log("receiveSerialPortData: $srcData")
// 重置无数据计时,并关闭已显示的超时提示弹窗
lastDataReceivedTime = System.currentTimeMillis()
noDataWarningDialog?.takeIf { it.isShowing }?.dismiss()
ScaleManager.instance.onDataReceived(srcData)
}
private var tipDialog: CommonDialog? = null
/** 最后一次成功收到串口数据的时间戳(毫秒) */
@@ -309,8 +282,7 @@ class HomeActivity : BaseActivity() {
noDataWarningDialog?.dismiss()
noDataWarningDialog = null
runBlocking { SerialPortManager.close() }
ScaleManager.instance.release()
runBlocking { ScaleManager.release() }
saveTaskJob?.cancel()
overdueTaskJob?.cancel()
EventBus.getDefault().unregister(this)
@@ -333,10 +305,10 @@ class HomeActivity : BaseActivity() {
lifecycleScope.launch {
if (isNeedOpenLock()) {
val openCmd = if (list[position].deviceNo in 1..5)
ScaleManager.instance.buildOpenLock1Cmd()
ScaleManager.buildOpenLock1Cmd()
else
ScaleManager.instance.buildOpenLock2Cmd()
ScaleManager.instance.sendCmd(openCmd)
ScaleManager.buildOpenLock2Cmd()
ScaleManager.sendCmd(openCmd)
}
launch(Intent(this@HomeActivity, ShelfActivity::class.java).also {
it.putExtra(ShelfActivity.SHELF_MODEL, list[position])
@@ -146,7 +146,7 @@ class ShelfActivity : BaseActivity() {
//binding.tvFoodWeight.text = "0克"
EventBus.getDefault().post(ClearShelfEvent(shelfModel!!.deviceNo, tempWeight))
}
sendCmd(ScaleManager.instance.buildGetStatusCmd())
sendCmd(ScaleManager.buildGetStatusCmd())
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.getGoodsListUiState.collect { state ->
@@ -359,7 +359,7 @@ class ShelfActivity : BaseActivity() {
private fun clearZero() {
lifecycleScope.launch {
val shelfNo = shelfModel?.deviceNo ?: 0
val zeroClearingCmd = ScaleManager.instance.buildScaleZeroCmd(shelfNo)
val zeroClearingCmd = ScaleManager.buildScaleZeroCmd(shelfNo)
log("编号${shelfNo}货架执行清零指令:${zeroClearingCmd}")
sendCmd(zeroClearingCmd)
// sendCmd(
@@ -369,7 +369,7 @@ class ShelfActivity : BaseActivity() {
realWeight = 0
// binding.tvFoodWeight.text = "${realWeight}克"
sendCmd(ScaleManager.instance.buildGetStatusCmd())
sendCmd(ScaleManager.buildGetStatusCmd())
startTime = System.currentTimeMillis()
Loading.show(this@ShelfActivity)
window.decorView.postDelayed({ Loading.dismiss() }, 10000)
@@ -12,14 +12,13 @@ import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import com.shuwei.intelligent.shelves.activity.LogActivity
import com.shuwei.intelligent.shelves.R
import com.shuwei.intelligent.shelves.databinding.ActivityBaseBinding
import com.shuwei.intelligent.shelves.databinding.PopupRightStatusMenuBinding
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.LIGHT_CLOSE
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.ext.clickWithDebounce
import com.shuwei.intelligent.shelves.utils.ext.dp
@@ -215,10 +214,8 @@ open class BaseActivity : AppCompatActivity() {
fun sendCmd(cmd: String) {
lifecycleScope.launch {
val result = SerialPortManager.send(cmd)
log("发送命令${cmd}结果:${result}")
}
ScaleManager.sendCmd(cmd)
log("发送命令: $cmd")
}
val fileLogger by lazy { FileLogger(this) }
@@ -12,59 +12,69 @@ import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.CMD_TIME_SYN
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.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.Locale
import java.util.TimeZone
/**
* 称重控制板协议管理器(SDK 入口)
*
* 职责:
* - 管理串口连接(open/startReceive/release
* - 接收串口原始分片数据,自动拼装帧并分发到对应回调
* - 内置各命令的自动应答逻辑(心跳/对时/卡号等)
* - 提供链式回调注册、常用指令构建、资源释放
*
* 设计原则:
* - 不依赖具体串口实现,通过 init(sendAction) 注入发送函数
* - 不依赖任何业务类,可直接复制到其他项目使用
* - 非线程安全,建议在同一线程(如串口IO线程或主线程)中使用
*
* 典型用法:
* ```kotlin
* // 初始化Activity/Application中)
* ScaleManager.instance
* .init { hexStr -> scope.launch { SerialPortManager.send(hexStr) } }
* // 初始化并注册回调
* ScaleManager
* .init(deviceId = App.deviceId, activateMap = ACTIVE_MAP)
* .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)
* // 打开串口并启动接收
* lifecycleScope.launch {
* if (ScaleManager.open()) {
* ScaleManager.startReceive { data ->
* // 主线程前置钩子,处理看门狗等副作用
* }
* }
* }
*
* // 主动发送指令
* ScaleManager.instance.sendCmd(ScaleManager.instance.buildGetStatusCmd())
* ScaleManager.sendCmd(ScaleManager.buildGetStatusCmd())
*
* // 页面销毁时释放回调(不影响其他页面注册的回调,若需完全释放则调用 release())
* // 页面销毁时释放
* runBlocking { ScaleManager.release() }
* ```
*/
class ScaleManager {
object ScaleManager {
companion object {
/** 默认单例,适用于单设备场景;多设备场景请自行实例化 */
@JvmStatic
val instance: ScaleManager by lazy { ScaleManager() }
}
/** 串口端口名,默认 /dev/ttyS1 */
var portName: String = "/dev/ttyS1"
/** 串口波特率,默认 115200 */
var baudRate: Int = 115200
private val parser = ScaleFrameParser()
private var sendAction: ((String) -> Unit)? = null
private var serialPort: AndroidSerialPort? = null
private var scope = CoroutineScope(Dispatchers.IO)
/** 激活码映射表:key=deviceId,value=完整激活应答帧十六进制字符串 */
private var activateMap: Map<String, String> = emptyMap()
/** 当前设备ID,用于激活码查表 */
private var currentDeviceId: String = ""
/** 当前设备激活码,非空时收到激活请求自动应答 */
private var activateCode: String = ""
/** 心跳应答:心跳上报间隔(秒),默认5秒 */
var heartbeatInterval: Int = 5
@@ -91,25 +101,78 @@ class ScaleManager {
// ----------------------------------------------------------------
/**
* 初始化,注入串口发送函数与激活码映射表
* 初始化,设置设备激活码
*
* @param sendAction 发送十六进制字符串指令的函数,在该函数内完成实际串口写入
* @param deviceId 当前设备ID,用于激活码查表
* @param activateMap 激活码映射表:key=deviceId,value=完整激活应答帧十六进制字符串;
* 传入后终端激活请求将由 SDK 内部自动应答,无需再注册 onActivate 回调
* @param activateCode 当前设备的激活应答帧十六进制字符串,非空时收到激活请求自动应答
* @return this,支持链式调用
*/
fun init(
sendAction: (String) -> Unit,
deviceId: String = "",
activateMap: Map<String, String> = emptyMap()
): ScaleManager {
this.sendAction = sendAction
this.currentDeviceId = deviceId
this.activateMap = activateMap
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?
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)
}
}
}
// ----------------------------------------------------------------
// 数据接收入口
// ----------------------------------------------------------------
@@ -243,11 +306,18 @@ class ScaleManager {
* @param hexStr 完整帧十六进制字符串,含协议头尾
*/
fun sendCmd(hexStr: String) {
val action = sendAction ?: run {
log("警告:ScaleManager 尚未初始化,请先调用 init(sendAction)")
if (!scope.isActive) {
log("警告:ScaleManager 串口未打开或已释放")
return
}
action(hexStr)
scope.launch {
try {
serialPort?.write(hexStr.hexToBytes())
} catch (e: Exception) {
e.printStackTrace()
log("发送命令失败: ${e.message}")
}
}
}
// ----------------------------------------------------------------
@@ -352,7 +422,10 @@ class ScaleManager {
* 释放所有资源:清空回调列表、发送函数及帧缓冲
* 通常在 Activity/Fragment 销毁或不再使用时调用
*/
fun release() {
suspend fun release() {
scope.cancel()
serialPort?.close()
serialPort = null
heartbeatListeners.clear()
statusListeners.clear()
lockListeners.clear()
@@ -364,9 +437,7 @@ class ScaleManager {
activateListeners.clear()
rawListeners.clear()
logListeners.clear()
sendAction = null
activateMap = emptyMap()
currentDeviceId = ""
activateCode = ""
parser.reset()
}
@@ -400,7 +471,7 @@ class ScaleManager {
*/
private fun handleActivate(frame: ScaleFrame, rawHex: String) {
log("收到[终端激活]指令,终端ID${frame.terminalId},固件版本:${frame.firmwareVersion},原始数据:$rawHex")
val activeCmd = activateMap[currentDeviceId]
val activeCmd = activateCode
if (!activeCmd.isNullOrBlank()) {
sendCmd(activeCmd)
log("激活码自动应答:$activeCmd")
@@ -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()
}
}