feat(scale): 新增主从设备秤数据联网同步功能
- 新增 ScaleWebSocketServer/Client、ScaleDataAggregator、ScaleServiceManager 等秤服务模块 - 新增 MasterScaleActivity(主设备秤数据总览,按设备分组展示IP+秤列表) - 新增 SlaveActivity(子设备专属页面,显示IP、主设备连接状态及本机秤数据) - SlaveActivity 改为横屏,MasterScaleActivity 保持竖屏 - 子设备连接状态通过 WebSocket onOpen/onClose 回调实时更新 - GlobalData 新增 DeviceRole 枚举,BaseApp 启动时初始化角色并启动秤服务 - SettingActivity 新增设备角色展示和秤数据监控入口 - Weigher2 支持外部设置串口路径,适配不同硬件子设备 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,8 @@ import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.shuwei.dish.match.base.BaseActivity
|
||||
import com.shuwei.dish.match.base.BaseApp
|
||||
import com.shuwei.dish.match.base.DeviceRole
|
||||
import com.shuwei.dish.match.base.GlobalData
|
||||
import com.shuwei.dish.match.databinding.ActivityInitBinding
|
||||
import com.shuwei.dish.match.db.AppRepository
|
||||
import com.shuwei.dish.match.http.UrlConfig
|
||||
@@ -15,6 +17,7 @@ import com.shuwei.dish.match.utils.AppUtil
|
||||
import com.shuwei.dish.match.utils.QRCodeUtil
|
||||
import com.shuwei.dish.match.utils.SpTool
|
||||
import com.shuwei.dish.match.utils.WeightUtil
|
||||
import com.shuwei.dish.match.utils.Weigher2
|
||||
import com.shuwei.dish.match.utils.ext.dp
|
||||
import com.shuwei.dish.match.utils.ext.startActivity
|
||||
import com.shuwei.dish.match.utils.ext.toObject
|
||||
@@ -78,12 +81,22 @@ class InitActivity : BaseActivity() {
|
||||
// binding.btnInit.invisible()
|
||||
|
||||
initViewModel()
|
||||
// 若有保存的串口配置则先应用(子设备串口可能与主设备不同)
|
||||
// val savedPort = SpTool.getString(SpTool.SCALE_DEVICE_PORT, "")
|
||||
// if (savedPort.isNotEmpty()) {
|
||||
// Weigher2.setDevicePort(savedPort)
|
||||
// }
|
||||
// TODO: 测试不同的串口配置---------------------2、5、3
|
||||
Weigher2.setDevicePort(
|
||||
if (GlobalData.deviceId == "8fc2ab34-2137-3112-acca-f884ea8736d4") {
|
||||
"/dev/ttyS7"
|
||||
} else {
|
||||
"/dev/ttyS4"
|
||||
}
|
||||
)
|
||||
WeightUtil.init()
|
||||
WeightUtil.getWeight()
|
||||
WeightUtil.startContinuousRead()
|
||||
// HttpUtil.getAppToken()
|
||||
|
||||
// HttpUtil.loopGetToken = true
|
||||
countDown()
|
||||
}
|
||||
|
||||
@@ -94,6 +107,12 @@ class InitActivity : BaseActivity() {
|
||||
}
|
||||
|
||||
private fun startNextPage() {
|
||||
// 子设备:直接进入小屏专属页面,不走原有大屏业务流程
|
||||
if (GlobalData.deviceRole == DeviceRole.SLAVE) {
|
||||
startActivity<SlaveActivity>()
|
||||
return
|
||||
}
|
||||
// 主设备:走原有路由逻辑
|
||||
val launchPageType = SpTool.getInt(SpTool.LAUNCH_PAGE_TYPE, -1)
|
||||
when (launchPageType) {
|
||||
0 -> {
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package com.shuwei.dish.match.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.shuwei.dish.match.base.BaseActivity
|
||||
import com.shuwei.dish.match.databinding.ActivityMasterScaleBinding
|
||||
import com.shuwei.dish.match.databinding.ListItemScaleDataBinding
|
||||
import com.shuwei.dish.match.scale.ScaleData
|
||||
import com.shuwei.dish.match.scale.ScaleServiceManager
|
||||
import com.shuwei.dish.match.utils.WeightUtil
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* 主设备秤数据总览页面(仅 MASTER 角色显示)
|
||||
* 聚合展示所有设备(本机 + 远端子设备)的实时秤数据
|
||||
* 数据按设备分组展示:设备号 + IP + 该设备下所有秤数据
|
||||
* 数据来源:ScaleServiceManager.allScales(StateFlow)
|
||||
*/
|
||||
class MasterScaleActivity : BaseActivity() {
|
||||
|
||||
private lateinit var binding: ActivityMasterScaleBinding
|
||||
|
||||
/**
|
||||
* 按设备分组后的数据模型
|
||||
* @param deviceId 设备 ID
|
||||
* @param ip 设备 IP 地址
|
||||
* @param scales 该设备下所有秤数据,按 address 排序
|
||||
*/
|
||||
private data class DeviceGroup(
|
||||
val deviceId: String,
|
||||
val ip: String,
|
||||
val scales: List<ScaleData>
|
||||
)
|
||||
|
||||
private val groupList = mutableListOf<DeviceGroup>()
|
||||
private val adapter = DeviceGroupAdapter(groupList)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityMasterScaleBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
setHeaderBackground()
|
||||
|
||||
binding.rvScaleList.layoutManager = LinearLayoutManager(this)
|
||||
binding.rvScaleList.adapter = adapter
|
||||
|
||||
observeScaleData()
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅 ScaleServiceManager 的全量秤数据 StateFlow
|
||||
* 每次有任一秤数据更新时,重新按设备分组并刷新列表
|
||||
*/
|
||||
private fun observeScaleData() {
|
||||
val flow = ScaleServiceManager.allScales ?: return
|
||||
|
||||
lifecycleScope.launch {
|
||||
flow.collectLatest { scaleMap ->
|
||||
// 按 deviceId 分组,本机设备排最前,其余按 deviceId 排序
|
||||
val localId = com.shuwei.dish.match.base.GlobalData.deviceId
|
||||
val groups = scaleMap.values
|
||||
.groupBy { it.deviceId }
|
||||
.entries
|
||||
.sortedWith(compareBy({ if (it.key == localId) 0 else 1 }, { it.key }))
|
||||
.map { (deviceId, scales) ->
|
||||
DeviceGroup(
|
||||
deviceId = deviceId,
|
||||
ip = scales.firstOrNull()?.ip ?: "",
|
||||
scales = scales.sortedBy { it.address }
|
||||
)
|
||||
}
|
||||
updateList(groups)
|
||||
updateStatusBar(scaleMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
private fun updateList(groups: List<DeviceGroup>) {
|
||||
groupList.clear()
|
||||
groupList.addAll(groups)
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
private fun updateStatusBar(scaleMap: Map<String, ScaleData>) {
|
||||
val deviceCount = scaleMap.values.map { it.deviceId }.distinct().size
|
||||
val scaleCount = scaleMap.size
|
||||
binding.tvConnectionStatus.text = "已发现设备:$deviceCount 台 / 共 $scaleCount 个秤"
|
||||
}
|
||||
|
||||
/** 设备分组适配器,每项展示一台设备及其所有秤数据 */
|
||||
private inner class DeviceGroupAdapter(private val data: List<DeviceGroup>) :
|
||||
RecyclerView.Adapter<DeviceGroupAdapter.VH>() {
|
||||
|
||||
inner class VH(val binding: ListItemScaleDataBinding) :
|
||||
RecyclerView.ViewHolder(binding.root)
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = VH(
|
||||
ListItemScaleDataBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
)
|
||||
|
||||
override fun getItemCount() = data.size
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
override fun onBindViewHolder(holder: VH, position: Int) {
|
||||
val group = data[position]
|
||||
holder.binding.tvDeviceLabel.text = "设备:${group.deviceId}"
|
||||
holder.binding.tvDeviceIp.text = "IP:${group.ip.ifEmpty { "未知" }}"
|
||||
|
||||
// 清空旧秤行,重新填充
|
||||
val container: LinearLayout = holder.binding.llScaleContainer
|
||||
container.removeAllViews()
|
||||
|
||||
group.scales.forEach { scale ->
|
||||
val stateStr = when (scale.state) {
|
||||
WeightUtil.STATE_STABLE -> "稳定"
|
||||
WeightUtil.STATE_UNSTABLE -> "不稳定"
|
||||
WeightUtil.STATE_OVER_WEIGHT -> "超量"
|
||||
else -> "${scale.state}"
|
||||
}
|
||||
// 每行:秤编号 重量 状态
|
||||
val row = LinearLayout(holder.itemView.context).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
).also { it.topMargin = 4.dpToPx() }
|
||||
}
|
||||
row.addView(makeTextView("秤 ${scale.address}", 22, weight = 1f))
|
||||
row.addView(makeTextView("${scale.weight} g", 26, bold = true, weight = 2f))
|
||||
row.addView(makeTextView(stateStr, 18, weight = 1f))
|
||||
container.addView(row)
|
||||
}
|
||||
}
|
||||
|
||||
/** 创建秤行内的 TextView */
|
||||
private fun makeTextView(
|
||||
text: String,
|
||||
spSize: Int,
|
||||
bold: Boolean = false,
|
||||
weight: Float = 1f
|
||||
): TextView = TextView(this@MasterScaleActivity).apply {
|
||||
this.text = text
|
||||
textSize = spSize.toFloat()
|
||||
if (bold) setTypeface(null, android.graphics.Typeface.BOLD)
|
||||
setTextColor(
|
||||
if (bold) getColor(com.shuwei.dish.match.R.color.home_title)
|
||||
else getColor(com.shuwei.dish.match.R.color.home_sub_title)
|
||||
)
|
||||
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, weight)
|
||||
}
|
||||
}
|
||||
|
||||
/** dp 转 px 工具 */
|
||||
private fun Int.dpToPx(): Int =
|
||||
(this * resources.displayMetrics.density + 0.5f).toInt()
|
||||
}
|
||||
|
||||
@@ -14,13 +14,17 @@ import androidx.camera.view.PreviewView
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import com.shuwei.dish.match.R
|
||||
import com.shuwei.dish.match.base.BaseActivity
|
||||
import com.shuwei.dish.match.base.DeviceRole
|
||||
import com.shuwei.dish.match.base.GlobalData
|
||||
import com.shuwei.dish.match.databinding.ActivitySettingBinding
|
||||
import com.shuwei.dish.match.databinding.LayoutCameraPreviewBinding
|
||||
import com.shuwei.dish.match.ui.fragment.CollectFragment
|
||||
import com.shuwei.dish.match.ui.fragment.DeviceConfigFragment
|
||||
import com.shuwei.dish.match.utils.CameraUtils
|
||||
import com.shuwei.dish.match.utils.SpTool
|
||||
import com.shuwei.dish.match.utils.ext.dp
|
||||
import com.shuwei.dish.match.utils.ext.gone
|
||||
import com.shuwei.dish.match.utils.ext.startActivity
|
||||
import com.shuwei.dish.match.utils.ext.toast
|
||||
import com.shuwei.dish.match.utils.ext.visible
|
||||
|
||||
@@ -77,10 +81,28 @@ class SettingActivity : BaseActivity() {
|
||||
updateButtonColors(isDeviceConfigSelected = false)
|
||||
}
|
||||
|
||||
// 设备角色只读展示,角色由设备 ID 决定,不可手动切换
|
||||
updateRoleButtonText()
|
||||
binding.btnDeviceRole.isEnabled = false
|
||||
|
||||
// 主设备显示「秤数据监控」入口
|
||||
if (GlobalData.deviceRole == DeviceRole.MASTER) {
|
||||
binding.btnScaleMonitor.visible()
|
||||
binding.btnScaleMonitor.setOnClickListener {
|
||||
startActivity<MasterScaleActivity>()
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化按钮颜色(默认设备配置为选中状态)
|
||||
updateButtonColors(isDeviceConfigSelected = true)
|
||||
}
|
||||
|
||||
/** 刷新角色按钮文字 */
|
||||
private fun updateRoleButtonText() {
|
||||
val roleLabel = if (GlobalData.deviceRole == DeviceRole.MASTER) "主设备" else "子设备"
|
||||
binding.btnDeviceRole.text = "设备角色:$roleLabel"
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新底部菜单按钮的颜色状态
|
||||
* @param isDeviceConfigSelected 设备配置按钮是否被选中
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package com.shuwei.dish.match.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.shuwei.dish.match.base.BaseActivity
|
||||
import com.shuwei.dish.match.base.GlobalData
|
||||
import com.shuwei.dish.match.databinding.ActivitySlaveBinding
|
||||
import com.shuwei.dish.match.databinding.ListItemScaleRowBinding
|
||||
import com.shuwei.dish.match.scale.ScaleServiceManager
|
||||
import com.shuwei.dish.match.utils.WeightUtil
|
||||
|
||||
/**
|
||||
* 子设备专属页面(小屏幕设备使用)
|
||||
* 仅展示本机秤的实时数据和主设备连接状态
|
||||
* 布局简洁,适配小屏幕
|
||||
*/
|
||||
class SlaveActivity : BaseActivity() {
|
||||
|
||||
private lateinit var binding: ActivitySlaveBinding
|
||||
|
||||
/** 本机秤数据列表:address → (weight, state) */
|
||||
private data class ScaleItem(val address: Int, val weight: Double, val state: Int)
|
||||
|
||||
private val scaleList = mutableListOf<ScaleItem>()
|
||||
private val adapter = ScaleAdapter(scaleList)
|
||||
|
||||
/** 已连接的主设备数量(有连接即代表主设备在线) */
|
||||
private var masterConnected = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivitySlaveBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
setHeaderBackground()
|
||||
|
||||
binding.tvDeviceId.text = "设备:${GlobalData.deviceId}"
|
||||
binding.tvDeviceIp.text = "IP:${getLocalIpAddress()}"
|
||||
binding.rvScaleList.layoutManager = LinearLayoutManager(this)
|
||||
binding.rvScaleList.adapter = adapter
|
||||
|
||||
updateMasterStatus(false)
|
||||
listenLocalScales()
|
||||
listenMasterConnection()
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅主设备连接状态,回调在非主线程,切换到主线程更新 UI
|
||||
*/
|
||||
private fun listenMasterConnection() {
|
||||
ScaleServiceManager.onMasterConnectionChanged = { connected ->
|
||||
runOnUiThread { updateMasterStatus(connected) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听本机 WeightUtil 回调,实时刷新秤列表
|
||||
*/
|
||||
private fun listenLocalScales() {
|
||||
WeightUtil.addWeightListener(TAG) { address, state, weight ->
|
||||
runOnUiThread {
|
||||
val idx = scaleList.indexOfFirst { it.address == address }
|
||||
val item = ScaleItem(address, weight, state)
|
||||
if (idx >= 0) {
|
||||
scaleList[idx] = item
|
||||
adapter.notifyItemChanged(idx)
|
||||
} else {
|
||||
scaleList.add(item)
|
||||
scaleList.sortBy { it.address }
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新主设备连接状态文字
|
||||
* 由 ScaleWebSocketServer 的 onOpen/onClose 回调驱动(可在后续扩展中接入)
|
||||
*/
|
||||
@SuppressLint("SetTextI18n")
|
||||
fun updateMasterStatus(connected: Boolean) {
|
||||
masterConnected = connected
|
||||
binding.tvMasterStatus.text = if (connected) "主设备:已连接 ✓" else "主设备:未连接"
|
||||
binding.tvMasterStatus.setTextColor(
|
||||
getColor(if (connected) android.R.color.holo_green_dark else android.R.color.holo_red_light)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
ScaleServiceManager.onMasterConnectionChanged = null
|
||||
WeightUtil.removeWeightListener(TAG)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "SlaveActivity"
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本机 WiFi IP 地址
|
||||
* 优先使用 WifiManager,获取失败时返回"未知"
|
||||
*/
|
||||
@SuppressLint("WifiManagerLeak")
|
||||
private fun getLocalIpAddress(): String {
|
||||
return try {
|
||||
val wifiManager = applicationContext.getSystemService(WIFI_SERVICE) as WifiManager
|
||||
val ip = wifiManager.connectionInfo.ipAddress
|
||||
if (ip == 0) "未知" else
|
||||
"${ip and 0xFF}.${ip shr 8 and 0xFF}.${ip shr 16 and 0xFF}.${ip shr 24 and 0xFF}"
|
||||
} catch (e: Exception) {
|
||||
"未知"
|
||||
}
|
||||
}
|
||||
|
||||
private class ScaleAdapter(private val data: List<ScaleItem>) :
|
||||
RecyclerView.Adapter<ScaleAdapter.VH>() {
|
||||
|
||||
inner class VH(val binding: ListItemScaleRowBinding) :
|
||||
RecyclerView.ViewHolder(binding.root)
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = VH(
|
||||
ListItemScaleRowBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
)
|
||||
|
||||
override fun getItemCount() = data.size
|
||||
|
||||
override fun onBindViewHolder(holder: VH, position: Int) {
|
||||
val item = data[position]
|
||||
val stateStr = when (item.state) {
|
||||
WeightUtil.STATE_STABLE -> "稳定"
|
||||
WeightUtil.STATE_UNSTABLE -> "不稳定"
|
||||
WeightUtil.STATE_OVER_WEIGHT -> "超量"
|
||||
else -> "${item.state}"
|
||||
}
|
||||
holder.binding.tvScaleLabel.text = "秤 ${item.address}"
|
||||
holder.binding.tvWeight.text = "${item.weight} g"
|
||||
holder.binding.tvState.text = stateStr
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,6 +161,12 @@ class CollectFragment : BaseFragment<FragmentCollectBinding>() {
|
||||
toast("每次只允许保存${MAX_COUNT}条数据")
|
||||
return
|
||||
}
|
||||
val index = foodCollectionList.indexOfFirst { it.imageFile == null }
|
||||
if (index == -1) {
|
||||
toast("每次只允许保存${MAX_COUNT}条数据")
|
||||
hideWaitingDialog()
|
||||
return
|
||||
}
|
||||
showWaitingDialog("采集中……")
|
||||
settingActivity.takePhoto(succCallback = cameraCallback, failCallback = { errMsg ->
|
||||
hideWaitingDialog()
|
||||
|
||||
Reference in New Issue
Block a user