3 Commits
Author SHA1 Message Date
lvmengandClaude Sonnet 4.6 9ae0d709f2 fix(scale): 修复子设备IP收到数据后仍显示"未知"的问题
setDeviceIp 新增缓存回填逻辑:若设备已有缓存数据且 ip 为空,
立即用新 IP 更新所有相关条目并触发 StateFlow,
解决早期数据包在 IP 到达前入缓存导致 UI 无法更新的时序问题

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 18:19:07 +08:00
lvmengandClaude Sonnet 4.6 34adc0fe56 fix(scale): 修复主从设备IP显示异常及RecyclerView滑动崩溃
- 新增 NetworkUtil 统一获取本机IP,兼容 Android 12+(ConnectivityManager)和低版本(WifiManager)
- 主设备IP改为实时读取,修复App启动时网络未就绪导致IP缓存为空的问题
- MasterScaleActivity/SlaveActivity 关闭RecyclerView默认ItemAnimator,消除高频刷新时的闪烁
- BaseActivity.hideSystemBars 加防重入标志,避免重复调用setDecorFitsSystemWindows触发布局重绘导致滑动崩溃
- 新增 CrashHandler,崩溃时记录日志并自动重启应用

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 18:09:41 +08:00
lvmengandClaude Sonnet 4.6 29228b069b 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>
2026-04-08 17:15:48 +08:00
27 changed files with 1709 additions and 9 deletions
+1
View File
@@ -105,6 +105,7 @@ dependencies {
implementation(libs.okhttp)
implementation(libs.logging.interceptor)
implementation(libs.retrofit)
implementation(libs.java.websocket)
implementation(libs.converter.gson)
implementation(libs.baserecyclerviewadapterhelper4)
+8
View File
@@ -93,6 +93,14 @@
android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity" />
<activity android:name="com.shuwei.dish.match.ui.SubmitFoodActivity" />
<activity
android:name="com.shuwei.dish.match.ui.MasterScaleActivity"
android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity" />
<activity
android:name="com.shuwei.dish.match.ui.SlaveActivity"
android:screenOrientation="landscape"
tools:ignore="DiscouragedApi,LockedOrientationActivity" />
<receiver
android:name=".utils.BootReceiver"
@@ -14,6 +14,9 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import com.shuwei.dish.match.R
import com.shuwei.dish.match.databinding.ActivityBaseBinding
import com.shuwei.dish.match.dialog.Loading
@@ -70,10 +73,11 @@ open class BaseActivity : AppCompatActivity() {
}
fun hideStatusBar() {
enableEdgeToEdge()
// val uiOptions = (View.SYSTEM_UI_FLAG_FULLSCREEN // 隐藏状态栏
// or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION) // 隐藏导航栏(可选)
window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN
// enableEdgeToEdge()
//// val uiOptions = (View.SYSTEM_UI_FLAG_FULLSCREEN // 隐藏状态栏
//// or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION) // 隐藏导航栏(可选)
// window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN
hideSystemBars()
}
fun statusBarDarkFont(enable: Boolean) {
@@ -227,4 +231,38 @@ open class BaseActivity : AppCompatActivity() {
activityCallback?.invoke(it.data)
}
/**
* 窗口焦点变化时回调
* 场景:弹出 Dialog 后焦点丢失,Dialog 消失后焦点恢复,此时需重新隐藏系统栏,
* 避免系统栏在 Dialog 关闭后意外"复活"
*/
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) {
hideSystemBars()
}
}
/**
* 隐藏状态栏和导航栏
* 使用 WindowInsetsControllerCompatAndroidx 推荐方式,向下兼容性好)
* - setDecorFitsSystemWindows 只在首次调用时设置,避免反复触发布局重绘
* (重绘会导致 RecyclerView 滑动时抛出 "view is not a child, cannot hide" 异常)
* - 每次调用仍重新执行 hide(),保证系统栏在滑出后能被重新隐藏
*/
private var systemBarsConfigured = false
private fun hideSystemBars() {
if (!systemBarsConfigured) {
// 只执行一次:允许布局延伸到系统栏区域
WindowCompat.setDecorFitsSystemWindows(window, false)
systemBarsConfigured = true
}
WindowInsetsControllerCompat(window, window.decorView).apply {
hide(WindowInsetsCompat.Type.systemBars())
systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
}
}
@@ -10,9 +10,11 @@ import com.shuwei.dish.match.R
import com.shuwei.dish.match.db.DatabaseProvider
import com.shuwei.dish.match.objbox.FoodModule
import com.shuwei.dish.match.objbox.ObjectBox
import com.shuwei.dish.match.scale.ScaleServiceManager
import com.shuwei.dish.match.ui.InitActivity.Companion.TAG
import com.shuwei.dish.match.utils.AppUtil
import com.shuwei.dish.match.utils.BootReceiver
import com.shuwei.dish.match.utils.CrashHandler
class BaseApp : Application() {
@@ -22,15 +24,32 @@ class BaseApp : Application() {
override fun onCreate() {
super.onCreate()
instance = this
CrashHandler.init(this)
val deviceId = AppUtil.getUDID(this)
Log.d(TAG, "onCreate: deviceId=$deviceId")
GlobalData.deviceId = deviceId
// 根据设备 ID 判断角色:指定 ID 为主设备,其余为子设备
GlobalData.deviceRole = if (deviceId == "8fc2ab34-2137-3112-acca-f884ea8736d4") {
DeviceRole.MASTER
} else {
DeviceRole.SLAVE
}
Log.d(TAG, "设备角色: ${GlobalData.deviceRole}")
// 启动秤服务(主设备多启动发现+聚合,子设备只启动服务端+注册)
ScaleServiceManager.start(this)
// GlobalData.deviceId = "39a7abdd06b3c7ab"
val filter = IntentFilter(Intent.ACTION_BOOT_COMPLETED)
registerReceiver(BootReceiver(), filter)
ObjectBox.init(this)
}
override fun onTerminate() {
ScaleServiceManager.stop()
super.onTerminate()
}
companion object {
// const val canteenId = "1678234139391512577"
@@ -1,12 +1,30 @@
package com.shuwei.dish.match.base
object GlobalData {
var appBaseUrl: String = "https://dev.yixiong-tech.com:8081"
/**
* 设备角色枚举
* MASTER:主设备,负责汇聚所有设备的秤数据并展示大屏界面
* SLAVE:子设备,负责推送本机秤数据给主设备
*/
enum class DeviceRole { MASTER, SLAVE }
object GlobalData {
var appBaseUrl: String = TEST_BASE_URL
/**
* 具体业务 BaseUrl
*/
const val TEST_BASE_URL = "http://192.168.1.201:14801"
const val UAT_BASE_URL = "https://dev.yixiong-tech.com:8081"
const val PROD_BASE_URL = "https://api.dm.yixiong-tech.com:8443"
/**
* 设备id
*/
var deviceId: String = ""
/**
* 当前设备角色,启动时从 SpTool 读取,默认为 SLAVE
*/
var deviceRole: DeviceRole = DeviceRole.SLAVE
var appId = "Hkz1rBk6PZXbS8KwKr67K2eZtsz8bRoMHLg64bUdgCZj"
var sdkKey = "AabXs3sHM8UhhE7oCGf4LVMLrdkGb1nJNksbjjTdVt7k"
}
@@ -0,0 +1,116 @@
package com.shuwei.dish.match.scale
import android.content.Context
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.util.Log
/**
* mDNS 设备发现管理器(仅主设备运行)
* 持续扫描局域网,发现或丢失 DishMatch 子设备时通过回调通知连接管理器
*/
class MdnsDiscoveryManager(private val context: Context) {
companion object {
private const val TAG = "MdnsDiscoveryManager"
}
private val nsdManager: NsdManager by lazy {
context.getSystemService(Context.NSD_SERVICE) as NsdManager
}
private var discoveryListener: NsdManager.DiscoveryListener? = null
private var isDiscovering = false
/**
* 发现新设备时的回调:(deviceId, host, port)
*/
var onDeviceFound: ((deviceId: String, host: String, port: Int) -> Unit)? = null
/**
* 设备离线时的回调:(deviceId)
*/
var onDeviceLost: ((deviceId: String) -> Unit)? = null
/**
* 开始扫描局域网中的 DishMatch 设备
*/
fun startDiscovery() {
if (isDiscovering) return
discoveryListener = object : NsdManager.DiscoveryListener {
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
Log.e(TAG, "开始扫描失败: errorCode=$errorCode")
isDiscovering = false
}
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
Log.e(TAG, "停止扫描失败: errorCode=$errorCode")
}
override fun onDiscoveryStarted(serviceType: String) {
Log.d(TAG, "mDNS 扫描已启动")
isDiscovering = true
}
override fun onDiscoveryStopped(serviceType: String) {
Log.d(TAG, "mDNS 扫描已停止")
isDiscovering = false
}
override fun onServiceFound(service: NsdServiceInfo) {
// 只处理 DishMatch 服务
if (!service.serviceName.startsWith("DishMatch-")) return
Log.d(TAG, "发现设备: ${service.serviceName}")
resolveService(service)
}
override fun onServiceLost(service: NsdServiceInfo) {
if (!service.serviceName.startsWith("DishMatch-")) return
val deviceId = service.serviceName.removePrefix("DishMatch-")
Log.d(TAG, "设备离线: $deviceId")
onDeviceLost?.invoke(deviceId)
}
}
nsdManager.discoverServices(
MdnsRegisterManager.SERVICE_TYPE,
NsdManager.PROTOCOL_DNS_SD,
discoveryListener
)
}
/**
* 解析服务获取 IP 和端口
*/
private fun resolveService(service: NsdServiceInfo) {
nsdManager.resolveService(service, object : NsdManager.ResolveListener {
override fun onResolveFailed(info: NsdServiceInfo, errorCode: Int) {
Log.e(TAG, "解析设备地址失败: ${info.serviceName}, errorCode=$errorCode")
}
override fun onServiceResolved(info: NsdServiceInfo) {
val deviceId = info.serviceName.removePrefix("DishMatch-")
val host = info.host?.hostAddress ?: return
val port = info.port
Log.d(TAG, "设备地址已解析: $deviceId -> $host:$port")
onDeviceFound?.invoke(deviceId, host, port)
}
})
}
/**
* 停止扫描,在应用退出时调用
*/
fun stopDiscovery() {
if (!isDiscovering) return
discoveryListener?.let {
try {
nsdManager.stopServiceDiscovery(it)
} catch (e: Exception) {
Log.e(TAG, "停止扫描异常: ${e.message}")
}
}
discoveryListener = null
}
}
@@ -0,0 +1,80 @@
package com.shuwei.dish.match.scale
import android.content.Context
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.util.Log
/**
* mDNS 服务注册管理器(主设备和子设备均运行)
* 将本机 WebSocket 服务以 mDNS 形式广播到局域网,让主设备能发现本机 IP:PORT
*/
class MdnsRegisterManager(private val context: Context) {
companion object {
private const val TAG = "MdnsRegisterManager"
/** mDNS 服务类型,所有 DishMatch 设备使用统一类型 */
const val SERVICE_TYPE = "_dishmatch._tcp."
/** WebSocket 服务监听端口 */
const val WS_PORT = 8765
}
private val nsdManager: NsdManager by lazy {
context.getSystemService(Context.NSD_SERVICE) as NsdManager
}
private var registrationListener: NsdManager.RegistrationListener? = null
private var isRegistered = false
/**
* 向局域网注册 mDNS 服务
* @param deviceId 当前设备 ID,作为服务名称后缀,便于主设备识别
*/
fun register(deviceId: String) {
if (isRegistered) return
val serviceInfo = NsdServiceInfo().apply {
serviceName = "DishMatch-$deviceId"
serviceType = SERVICE_TYPE
port = WS_PORT
}
registrationListener = object : NsdManager.RegistrationListener {
override fun onRegistrationFailed(info: NsdServiceInfo, errorCode: Int) {
Log.e(TAG, "mDNS 注册失败, errorCode=$errorCode")
isRegistered = false
}
override fun onUnregistrationFailed(info: NsdServiceInfo, errorCode: Int) {
Log.e(TAG, "mDNS 注销失败, errorCode=$errorCode")
}
override fun onServiceRegistered(info: NsdServiceInfo) {
Log.d(TAG, "mDNS 注册成功: ${info.serviceName}")
isRegistered = true
}
override fun onServiceUnregistered(info: NsdServiceInfo) {
Log.d(TAG, "mDNS 已注销: ${info.serviceName}")
isRegistered = false
}
}
nsdManager.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, registrationListener)
}
/**
* 注销 mDNS 服务,在应用退出时调用
*/
fun unregister() {
if (!isRegistered) return
registrationListener?.let {
try {
nsdManager.unregisterService(it)
} catch (e: Exception) {
Log.e(TAG, "注销 mDNS 异常: ${e.message}")
}
}
registrationListener = null
}
}
@@ -0,0 +1,19 @@
package com.shuwei.dish.match.scale
/**
* 单个秤的数据快照
* @param deviceId 所属设备 ID
* @param address 秤地址编号(硬件 address,对应 WeightUtil 回调的 address 参数)
* @param weight 重量,单位克
* @param state 秤状态:1=稳定,0=不稳定,2=量程溢出(对应 WeightUtil 中的 STATE_* 常量)
* @param ts 数据时间戳(毫秒)
* @param ip 所属设备的 IP 地址,用于 UI 展示;网络传输数据中可能为空
*/
data class ScaleData(
val deviceId: String,
val address: Int,
val weight: Double,
val state: Int,
val ts: Long,
val ip: String = ""
)
@@ -0,0 +1,122 @@
package com.shuwei.dish.match.scale
import android.util.Log
import com.shuwei.dish.match.utils.WeightUtil
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.concurrent.ConcurrentHashMap
/**
* 秤数据聚合器(仅主设备运行)
* 统一管理本机秤(直接读取 WeightUtil)和远端子设备秤(通过 WebSocket 接收)的数据
* 通过 StateFlow 将所有秤的最新快照暴露给 UI 层
*
* 数据 Map 的 key 格式:"{deviceId}#{address}",便于 UI 按设备分组展示
*/
class ScaleDataAggregator(private val localDeviceId: String) {
companion object {
private const val TAG = "ScaleDataAggregator"
/** 本机秤监听器的 key,防止与其他监听器冲突 */
private const val LOCAL_LISTENER_KEY = "ScaleDataAggregator_local"
}
/** 所有秤的最新数据,UI 层通过此 Flow 订阅更新 */
private val _allScales = MutableStateFlow<Map<String, ScaleData>>(emptyMap())
val allScales: StateFlow<Map<String, ScaleData>> = _allScales.asStateFlow()
/** 内部缓存,并发安全 */
private val cache = ConcurrentHashMap<String, ScaleData>()
/** deviceId → IP 地址映射,用于写入 ScaleData.ip 字段 */
private val deviceIpMap = ConcurrentHashMap<String, String>()
/**
* 设置指定设备的 IP 地址
* 若缓存中已有该设备的数据且 ip 为空,立即回填并触发 StateFlow 更新,
* 解决早期数据包在 IP 到达前入缓存导致一直显示"未知"的问题
* @param deviceId 设备 ID
* @param ip IP 地址字符串
*/
fun setDeviceIp(deviceId: String, ip: String) {
if (ip.isEmpty()) return
deviceIpMap[deviceId] = ip
// 回填该设备所有 ip 为空的缓存条目
val keys = cache.keys.filter { it.startsWith("$deviceId#") }
if (keys.isEmpty()) return
var updated = false
keys.forEach { key ->
val old = cache[key] ?: return@forEach
if (old.ip.isEmpty()) {
cache[key] = old.copy(ip = ip)
updated = true
}
}
if (updated) {
_allScales.value = HashMap(cache)
Log.d(TAG, "已回填设备IP: $deviceId -> $ip, 共 ${keys.size}")
}
}
/**
* 启动本机秤数据监听
* 主设备本机秤直接读取 WeightUtil,无需经过网络
*/
fun startLocalCollection() {
WeightUtil.addWeightListener(LOCAL_LISTENER_KEY) { address, state, weight ->
val data = ScaleData(
deviceId = localDeviceId,
address = address,
weight = weight,
state = state,
ts = System.currentTimeMillis(),
ip = deviceIpMap[localDeviceId] ?: ""
)
updateCache(data)
}
Log.d(TAG, "本机秤监听已启动, deviceId=$localDeviceId")
}
/**
* 处理来自 WebSocket 客户端的远端秤数据
* 由 ScaleWebSocketClient.onScaleData 回调触发
* 自动补充该设备已记录的 IP 信息
*/
fun onRemoteScaleData(data: ScaleData) {
val ip = deviceIpMap[data.deviceId] ?: data.ip
updateCache(if (ip.isNotEmpty() && data.ip.isEmpty()) data.copy(ip = ip) else data)
}
/**
* 移除指定设备的所有秤数据(设备离线时调用)
*/
fun removeDevice(deviceId: String) {
val keysToRemove = cache.keys.filter { it.startsWith("$deviceId#") }
if (keysToRemove.isEmpty()) return
keysToRemove.forEach { cache.remove(it) }
deviceIpMap.remove(deviceId)
_allScales.value = HashMap(cache)
Log.d(TAG, "已移除设备数据: $deviceId, 共 ${keysToRemove.size} 个秤")
}
/**
* 停止本机秤监听并清空所有数据
*/
fun stop() {
WeightUtil.removeWeightListener(LOCAL_LISTENER_KEY)
cache.clear()
deviceIpMap.clear()
_allScales.value = emptyMap()
Log.d(TAG, "ScaleDataAggregator 已停止")
}
/**
* 更新缓存并发布新快照到 StateFlow
*/
private fun updateCache(data: ScaleData) {
val key = "${data.deviceId}#${data.address}"
cache[key] = data
_allScales.value = HashMap(cache)
}
}
@@ -0,0 +1,133 @@
package com.shuwei.dish.match.scale
import android.content.Context
import android.util.Log
import com.shuwei.dish.match.base.DeviceRole
import com.shuwei.dish.match.base.GlobalData
import com.shuwei.dish.match.utils.NetworkUtil
import kotlinx.coroutines.flow.StateFlow
/**
* 秤服务统一门面(单例)
* 根据当前设备角色(MASTER/SLAVE)启动对应的服务组合:
*
* 所有设备均运行:
* - MdnsRegisterManager:将本机 WebSocket 服务广播到局域网
* - ScaleWebSocketServer:对外提供本机秤数据推送服务
*
* 仅主设备(MASTER)额外运行:
* - MdnsDiscoveryManager:发现局域网内子设备
* - ScaleWebSocketClient:连接子设备 WebSocket
* - ScaleDataAggregator:聚合本机 + 远端所有秤数据
*
* 使用方式:
* 在 Application.onCreate() 中调用 ScaleServiceManager.start(context)
* 在 Application.onTerminate() 中调用 ScaleServiceManager.stop()
* 主设备 UI 通过 ScaleServiceManager.allScales 订阅全量秤数据
*/
object ScaleServiceManager {
private const val TAG = "ScaleServiceManager"
private var mdnsRegister: MdnsRegisterManager? = null
private var wsServer: ScaleWebSocketServer? = null
// 以下仅主设备使用
private var mdnsDiscovery: MdnsDiscoveryManager? = null
private var wsClient: ScaleWebSocketClient? = null
private var aggregator: ScaleDataAggregator? = null
/**
* 所有秤数据的实时流,仅主设备有效;子设备返回 null
* UI 层通过 lifecycleScope.launch { allScales?.collect { ... } } 订阅
*/
val allScales: StateFlow<Map<String, ScaleData>>?
get() = aggregator?.allScales
/**
* 主设备连接状态回调(仅子设备使用)
* 设置后会立即转发给 wsServer;需在 start() 之后设置才能生效
*/
var onMasterConnectionChanged: ((connected: Boolean) -> Unit)?
get() = wsServer?.onConnectionChanged
set(value) { wsServer?.onConnectionChanged = value }
/**
* 启动所有秤服务,在 Application.onCreate() 中调用
*/
fun start(context: Context) {
val deviceId = GlobalData.deviceId
val role = GlobalData.deviceRole
Log.d(TAG, "启动秤服务, deviceId=$deviceId, role=$role")
// 所有设备:启动 WebSocket 服务端 + mDNS 注册
wsServer = ScaleWebSocketServer(deviceId).also { it.start() }
mdnsRegister = MdnsRegisterManager(context).also { it.register(deviceId) }
if (role == DeviceRole.MASTER) {
startMasterServices(context, deviceId)
}
}
/**
* 获取本机局域网 IP,委托给 NetworkUtil 统一处理版本兼容
*/
private fun getLocalIp(context: Context): String {
return NetworkUtil.getLocalIpAddress(context)
}
/**
* 启动主设备专属服务
*/
private fun startMasterServices(context: Context, deviceId: String) {
// 聚合器:统一管理本机 + 远端秤数据
aggregator = ScaleDataAggregator(deviceId).also {
// 记录本机 IP,供本机秤数据展示使用
it.setDeviceIp(deviceId, getLocalIp(context))
it.startLocalCollection()
}
// WebSocket 客户端
val client = ScaleWebSocketClient().also {
it.onScaleData = { data -> aggregator?.onRemoteScaleData(data) }
}
wsClient = client
// mDNS 发现:发现子设备 → 建立 WebSocket 连接;设备离线 → 断开连接并清除数据
mdnsDiscovery = MdnsDiscoveryManager(context).also { discovery ->
discovery.onDeviceFound = { remoteId, host, port ->
// 不连接自身
if (remoteId != deviceId) {
Log.d(TAG, "发现子设备 $remoteId, 建立连接")
// 记录子设备 IP,供 UI 展示使用
aggregator?.setDeviceIp(remoteId, host)
client.connect(remoteId, host, port)
}
}
discovery.onDeviceLost = { remoteId ->
Log.d(TAG, "子设备离线 $remoteId, 断开连接并清除数据")
client.disconnect(remoteId)
aggregator?.removeDevice(remoteId)
}
discovery.startDiscovery()
}
}
/**
* 停止所有秤服务,在 Application.onTerminate() 或退出时调用
*/
fun stop() {
mdnsDiscovery?.stopDiscovery()
wsClient?.shutdown()
aggregator?.stop()
wsServer?.stop()
mdnsRegister?.unregister()
mdnsDiscovery = null
wsClient = null
aggregator = null
wsServer = null
mdnsRegister = null
Log.d(TAG, "所有秤服务已停止")
}
}
@@ -0,0 +1,145 @@
package com.shuwei.dish.match.scale
import android.util.Log
import com.google.gson.Gson
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.ScheduledFuture
import java.util.concurrent.TimeUnit
/**
* 秤数据 WebSocket 客户端连接管理器(仅主设备运行)
* 管理与多台子设备的 WebSocket 长连接,支持断线自动重连(指数退避)
*/
class ScaleWebSocketClient {
companion object {
private const val TAG = "ScaleWebSocketClient"
/** 初始重连间隔(毫秒) */
private const val RECONNECT_BASE_MS = 2_000L
/** 最大重连间隔(毫秒) */
private const val RECONNECT_MAX_MS = 30_000L
}
private val gson = Gson()
private val httpClient = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.SECONDS) // WebSocket 长连接不设置读取超时
.pingInterval(20, TimeUnit.SECONDS) // 心跳保活
.build()
/** 各设备当前 WebSocket 连接,key = deviceId */
private val connections = ConcurrentHashMap<String, WebSocket>()
/** 各设备当前重连任务,key = deviceId */
private val reconnectTasks = ConcurrentHashMap<String, ScheduledFuture<*>>()
/** 各设备当前重连延迟,key = deviceId */
private val reconnectDelays = ConcurrentHashMap<String, Long>()
/** 已主动移除的设备集合,不对其触发重连 */
private val removedDevices = ConcurrentHashMap.newKeySet<String>()
private val scheduler: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor()
/** 收到秤数据时的回调,在子线程调用 */
var onScaleData: ((data: ScaleData) -> Unit)? = null
/**
* 连接到指定子设备
* @param deviceId 子设备 ID
* @param host 子设备 IP
* @param port 子设备 WebSocket 端口
*/
fun connect(deviceId: String, host: String, port: Int) {
removedDevices.remove(deviceId)
reconnectDelays[deviceId] = RECONNECT_BASE_MS
doConnect(deviceId, host, port)
}
private fun doConnect(deviceId: String, host: String, port: Int) {
val url = "ws://$host:$port"
val request = Request.Builder().url(url).build()
val ws = httpClient.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
Log.d(TAG, "已连接子设备: $deviceId ($url)")
connections[deviceId] = webSocket
reconnectDelays[deviceId] = RECONNECT_BASE_MS
reconnectTasks.remove(deviceId)?.cancel(false)
}
override fun onMessage(webSocket: WebSocket, text: String) {
try {
val data = gson.fromJson(text, ScaleData::class.java)
onScaleData?.invoke(data)
} catch (e: Exception) {
Log.w(TAG, "解析秤数据失败: ${e.message}")
}
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
Log.w(TAG, "连接子设备失败: $deviceId, ${t.message}")
connections.remove(deviceId)
scheduleReconnect(deviceId, host, port)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
Log.d(TAG, "子设备连接已关闭: $deviceId, reason=$reason")
connections.remove(deviceId)
scheduleReconnect(deviceId, host, port)
}
})
connections[deviceId] = ws
}
/**
* 指数退避重连
*/
private fun scheduleReconnect(deviceId: String, host: String, port: Int) {
if (removedDevices.contains(deviceId)) return
val delay = reconnectDelays[deviceId] ?: RECONNECT_BASE_MS
Log.d(TAG, "将在 ${delay}ms 后重连子设备: $deviceId")
val future = scheduler.schedule({
if (!removedDevices.contains(deviceId)) {
doConnect(deviceId, host, port)
}
}, delay, TimeUnit.MILLISECONDS)
reconnectTasks[deviceId]?.cancel(false)
reconnectTasks[deviceId] = future
// 下次重连延迟翻倍,上限 30s
reconnectDelays[deviceId] = minOf(delay * 2, RECONNECT_MAX_MS)
}
/**
* 断开指定子设备连接(不再重连)
* @param deviceId 子设备 ID
*/
fun disconnect(deviceId: String) {
removedDevices.add(deviceId)
reconnectTasks.remove(deviceId)?.cancel(false)
connections.remove(deviceId)?.close(1000, "设备离线")
reconnectDelays.remove(deviceId)
Log.d(TAG, "已断开子设备: $deviceId")
}
/**
* 关闭所有连接并释放资源,在应用退出时调用
*/
fun shutdown() {
val ids = connections.keys.toList()
ids.forEach { disconnect(it) }
scheduler.shutdownNow()
httpClient.dispatcher.executorService.shutdown()
Log.d(TAG, "ScaleWebSocketClient 已关闭")
}
}
@@ -0,0 +1,134 @@
package com.shuwei.dish.match.scale
import android.util.Log
import com.google.gson.Gson
import com.shuwei.dish.match.utils.WeightUtil
import org.java_websocket.WebSocket
import org.java_websocket.handshake.ClientHandshake
import org.java_websocket.server.WebSocketServer
import java.net.InetSocketAddress
/**
* 秤数据 WebSocket 服务端(主设备和子设备均运行)
* 监听 WeightUtil 回调,将本机秤数据实时推送给所有已连接的主设备客户端
*/
class ScaleWebSocketServer(private val deviceId: String) {
companion object {
private const val TAG = "ScaleWebSocketServer"
/** 节流间隔:两次推送之间的最小间隔(毫秒) */
private const val THROTTLE_MS = 100L
}
private val gson = Gson()
private var server: InternalServer? = null
/** 主设备连接状态变化回调:true=已连接,false=已断开;在主线程外调用,需自行切换线程 */
var onConnectionChanged: ((connected: Boolean) -> Unit)? = null
/** 每个秤地址的最近一次推送时间,用于节流 */
private val lastPushTime = mutableMapOf<Int, Long>()
/** 缓存各秤最新数据,新客户端连接时立即同步当前状态 */
private val latestData = mutableMapOf<Int, ScaleData>()
/**
* 启动 WebSocket 服务器,并注册 WeightUtil 回调
*/
fun start() {
server = InternalServer(MdnsRegisterManager.WS_PORT)
server?.isReuseAddr = true
server?.start()
Log.d(TAG, "WebSocket 服务端已启动,端口 ${MdnsRegisterManager.WS_PORT}")
// 注册本机秤数据监听
WeightUtil.addWeightListener(TAG) { address, state, weight ->
val now = System.currentTimeMillis()
// 节流:同一地址 100ms 内只推送一次
if (now - (lastPushTime[address] ?: 0L) < THROTTLE_MS) return@addWeightListener
lastPushTime[address] = now
val data = ScaleData(
deviceId = deviceId,
address = address,
weight = weight,
state = state,
ts = now
)
latestData[address] = data
broadcast(gson.toJson(data))
}
}
/**
* 停止 WebSocket 服务器,移除秤监听
*/
fun stop() {
WeightUtil.removeWeightListener(TAG)
try {
server?.stop(1000)
} catch (e: Exception) {
Log.e(TAG, "停止服务端异常: ${e.message}")
}
server = null
latestData.clear()
lastPushTime.clear()
Log.d(TAG, "WebSocket 服务端已停止")
}
/**
* 向所有已连接客户端广播消息
*/
private fun broadcast(json: String) {
server?.connections?.forEach { conn ->
if (conn.isOpen) {
try {
conn.send(json)
} catch (e: Exception) {
Log.w(TAG, "推送失败: ${e.message}")
}
}
}
}
/**
* 内部 WebSocket 服务器实现
*/
private inner class InternalServer(port: Int) :
WebSocketServer(InetSocketAddress(port)) {
override fun onOpen(conn: WebSocket, handshake: ClientHandshake) {
Log.d(TAG, "主设备已连接: ${conn.remoteSocketAddress}")
onConnectionChanged?.invoke(true)
// 新客户端连接后,立即推送所有秤的最新数据
latestData.values.forEach { data ->
try {
conn.send(gson.toJson(data))
} catch (e: Exception) {
Log.w(TAG, "初始推送失败: ${e.message}")
}
}
}
override fun onClose(conn: WebSocket, code: Int, reason: String, remote: Boolean) {
Log.d(TAG, "主设备已断开: ${conn.remoteSocketAddress}, reason=$reason")
// 若已无任何连接,通知子设备 UI 更新为未连接状态
if (server?.connections.isNullOrEmpty()) {
onConnectionChanged?.invoke(false)
}
}
override fun onMessage(conn: WebSocket, message: String) {
// 服务端无需处理来自主设备的消息
}
override fun onError(conn: WebSocket?, ex: Exception) {
Log.e(TAG, "WebSocket 服务端错误: ${ex.message}")
}
override fun onStart() {
Log.d(TAG, "WebSocket 服务端 onStart")
connectionLostTimeout = 60
}
}
}
@@ -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,172 @@
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.NetworkUtil
import com.shuwei.dish.match.utils.WeightUtil
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
/**
* 主设备秤数据总览页面(仅 MASTER 角色显示)
* 聚合展示所有设备(本机 + 远端子设备)的实时秤数据
* 数据按设备分组展示:设备号 + IP + 该设备下所有秤数据
* 数据来源:ScaleServiceManager.allScalesStateFlow
*/
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.itemAnimator = null // 关闭默认动画,避免数据频繁更新时出现闪烁错乱
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
// 本机 IP 实时读取,避免 App 启动时网络未就绪导致缓存为空
val localIp = NetworkUtil.getLocalIpAddress(this@MasterScaleActivity)
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 = if (deviceId == localId) localIp
else scales.firstOrNull { it.ip.isNotEmpty() }?.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,137 @@
package com.shuwei.dish.match.ui
import android.annotation.SuppressLint
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.NetworkUtil
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.itemAnimator = null // 关闭默认动画,避免数据频繁更新时出现闪烁错乱
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"
}
/**
* 获取本机局域网 IP,委托给 NetworkUtil 统一处理版本兼容
* 获取失败时返回"未知"
*/
private fun getLocalIpAddress(): String {
return NetworkUtil.getLocalIpAddress(this).ifEmpty { "未知" }
}
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()
@@ -0,0 +1,235 @@
package com.shuwei.dish.match.utils
import android.app.ActivityManager
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.Process
import android.util.Log
import com.shuwei.dish.match.ui.HomeActivity
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.PrintWriter
import java.io.StringWriter
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlin.system.exitProcess
/**
* 崩溃处理
*/
class CrashHandler private constructor(private val context: Context) :
Thread.UncaughtExceptionHandler {
companion object {
private const val TAG = "CrashHandler"
private const val CRASH_REPORTS_DIR = "crash_reports"
private const val LOG_LINES = 500 // 收集最近500行日志
@Volatile
private var instance: CrashHandler? = null
fun init(context: Context) {
if (instance == null) {
synchronized(CrashHandler::class.java) {
if (instance == null) {
instance = CrashHandler(context.applicationContext)
}
}
}
}
fun getCrashReportFiles(context: Context): Array<File> {
val crashDir = File(context.getExternalFilesDir(null), CRASH_REPORTS_DIR)
return if (crashDir.exists() && crashDir.isDirectory) {
crashDir.listFiles { _, name -> name.endsWith(".log") } ?: emptyArray()
} else {
emptyArray()
}
}
fun clearCrashReports(context: Context) {
getCrashReportFiles(context).forEach { it.delete() }
}
}
private val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
init {
Thread.setDefaultUncaughtExceptionHandler(this)
}
override fun uncaughtException(thread: Thread, ex: Throwable) {
handleException(thread, ex)
}
/**
* 自动重启app
*/
private fun restartApp() {
// 延迟1秒后重启应用
Handler(Looper.getMainLooper()).postDelayed({
val intent = Intent(context, HomeActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
val pendingIntent = PendingIntent.getActivity(
context, 0, intent,
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
)
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
alarmManager.set(AlarmManager.RTC, System.currentTimeMillis() + 100, pendingIntent)
Process.killProcess(Process.myPid())
exitProcess(1)
}, 1000)
}
private fun handleException(thread: Thread, ex: Throwable) {
// 收集设备信息和异常信息
val crashInfo = collectCrashInfo(thread, ex)
// 保存日志文件
saveCrashInfoToFile(crashInfo)
// 这里可以添加其他处理逻辑,比如上传到服务器等
restartApp()
}
private fun collectCrashInfo(thread: Thread, ex: Throwable): String {
return buildString {
// 收集设备信息
collectDeviceInfo(this)
// 收集应用日志
try {
append("\n\n").append(collectLogs())
} catch (e: OutOfMemoryError) {
append("collectLogs发生OutOfMemoryError:${e.message}")
}
// 收集线程和异常信息
append("\n\n========== Thread & Exception Info ==========\n")
append("Thread: ${thread.name}\n")
append("Stack Trace:\n")
val sw = StringWriter()
val pw = PrintWriter(sw)
ex.printStackTrace(pw)
var cause: Throwable? = ex.cause
while (cause != null) {
cause.printStackTrace(pw)
cause = cause.cause
}
pw.close()
append(sw.toString())
}
}
private fun collectDeviceInfo(sb: StringBuilder) {
sb.append("========== Device Info ==========\n")
try {
// 应用信息
val pm = context.packageManager
val pi = pm.getPackageInfo(context.packageName, 0)
sb.append("App Version: ${pi.versionName}_${pi.versionCode}\n")
// Android 版本信息
sb.append("OS Version: ${Build.VERSION.RELEASE}_${Build.VERSION.SDK_INT}\n")
// 设备信息
sb.append("Vendor: ${Build.MANUFACTURER}\n")
sb.append("Model: ${Build.MODEL}\n")
sb.append("CPU ABI: ${Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown"}\n")
// 其他信息
sb.append("Locale: ${Locale.getDefault()}\n")
sb.append(
"Current Time: ${
SimpleDateFormat(
"yyyy-MM-dd HH:mm:ss",
Locale.getDefault()
).format(Date())
}\n"
)
// 内存信息
val memoryInfo = ActivityManager.MemoryInfo()
val activityManager =
context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
activityManager.getMemoryInfo(memoryInfo)
sb.append("Available Memory: ${memoryInfo.availMem / (1024 * 1024)}MB\n")
sb.append("Total Memory: ${memoryInfo.totalMem / (1024 * 1024)}MB\n")
sb.append("Low Memory: ${memoryInfo.lowMemory}\n")
} catch (e: Exception) {
Log.e(TAG, "Error while collecting device info", e)
sb.append("Error while collecting device info: ${e.message}\n")
}
}
private fun collectLogs(): String {
return buildString {
append("========== Application Logs ==========\n")
try {
val process = Runtime.getRuntime().exec("logcat -d -v threadtime")
val reader = process.inputStream.bufferedReader()
val logLines = reader.readLines()
val start = kotlin.comparisons.maxOf(0, logLines.size - LOG_LINES)
logLines.subList(start, logLines.size).forEach {
append(it).append("\n")
}
} catch (e: IOException) {
Log.e(TAG, "Error collecting logs", e)
append("Error collecting logs: ${e.message}\n")
}
}
}
private fun saveCrashInfoToFile(crashInfo: String) {
try {
val time = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.getDefault()).format(Date())
val fileName = "crash_$time.log"
val crashDir = File(context.getExternalFilesDir(null), CRASH_REPORTS_DIR)
if (!crashDir.exists() && !crashDir.mkdirs()) {
Log.d(TAG,"Failed to create crash report directory")
return
}
val crashFile = File(crashDir, fileName)
FileOutputStream(crashFile).use { it.write(crashInfo.toByteArray()) }
Log.d(TAG,"Crash info saved to: ${crashFile.absolutePath}")
} catch (e: Exception) {
Log.e(TAG, "Error saving crash info to file", e)
}
}
/**
* 清理旧的崩溃日志
*/
fun cleanupOldCrashReports(maxAgeDays: Int = 7) {
val crashDir = File(context.getExternalFilesDir(null), CRASH_REPORTS_DIR)
if (!crashDir.exists() || !crashDir.isDirectory) return
val now = System.currentTimeMillis()
val maxAgeMillis = maxAgeDays * 24 * 60 * 60 * 1000L
crashDir.listFiles()?.forEach { file ->
if (file.lastModified() < now - maxAgeMillis) {
file.delete()
}
}
}
}
@@ -0,0 +1,62 @@
package com.shuwei.dish.match.utils
import android.content.Context
import android.net.ConnectivityManager
import android.net.LinkProperties
import android.net.wifi.WifiManager
import android.os.Build
import java.net.Inet4Address
/**
* 网络工具类
*/
object NetworkUtil {
/**
* 获取本机局域网 IPv4 地址
* - Android 12+API 31+):使用 ConnectivityManager + LinkProperties,避免废弃 API
* - Android 12 以下:回退到 WifiManager.connectionInfo.ipAddress
* @return IP 字符串,获取失败返回空字符串
*/
fun getLocalIpAddress(context: Context): String {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
getIpViaConnectivityManager(context)
} else {
getIpViaWifiManager(context)
}
}
/**
* Android 12+ 方式:通过 ConnectivityManager 获取活跃网络的 LinkProperties
*/
private fun getIpViaConnectivityManager(context: Context): String {
return try {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val network = cm.activeNetwork ?: return ""
val props: LinkProperties = cm.getLinkProperties(network) ?: return ""
props.linkAddresses
.map { it.address }
.filterIsInstance<Inet4Address>()
.firstOrNull { !it.isLoopbackAddress }
?.hostAddress ?: ""
} catch (e: Exception) {
""
}
}
/**
* Android 12 以下方式:通过 WifiManager 获取 IP(仅 WiFi 有效)
*/
@Suppress("DEPRECATION")
private fun getIpViaWifiManager(context: Context): String {
return try {
val wm = context.applicationContext
.getSystemService(Context.WIFI_SERVICE) as WifiManager
val ip = wm.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) {
""
}
}
}
@@ -9,6 +9,10 @@ object SpTool {
const val TOKEN = "token"
const val LAUNCH_PAGE_TYPE = "launchPageType"
const val DEVICE_ID = "deviceId"
/** 设备角色:存储值为 "MASTER" 或 "SLAVE",默认 "SLAVE" */
const val DEVICE_ROLE = "deviceRole"
/** 秤串口路径,默认 /dev/ttyS7,子设备可能需要配置为其他值 */
const val SCALE_DEVICE_PORT = "scaleDevicePort"
val pref = BaseApp.getSharedPref()!!
@@ -29,6 +29,16 @@ public class Weigher2 {
private static boolean mConnect = false;
private static Listener mListener;
/** 允许外部在 init() 前设置串口路径,用于不同硬件型号的子设备 */
public static void setDevicePort(String port) {
mDevicePort = port;
Log.d(TAG, "串口已设置为: " + port);
}
public static String getDevicePort() {
return mDevicePort;
}
public static void init() {
String var0;
if ((var0 = Build.MODEL) == "pcb_941") {
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:background="@color/bg_color">
<!-- 顶部标题 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="20dp"
android:layout_marginBottom="10dp"
android:text="全设备秤数据监控"
android:textColor="@color/home_title"
android:textSize="36sp"
android:textStyle="bold" />
<!-- 主设备连接状态栏 -->
<TextView
android:id="@+id/tvConnectionStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginBottom="16dp"
android:textColor="@color/home_sub_title"
android:textSize="24sp"
tools:text="已发现设备:3 台" />
<!-- 秤数据列表 -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvScaleList"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:overScrollMode="never"
android:padding="16dp" />
</LinearLayout>
@@ -37,6 +37,25 @@
android:textSize="26sp"
android:background="@android:color/transparent" />
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/btnDeviceRole"
android:layout_width="0dp"
android:layout_height="70dp"
android:layout_weight="1"
android:text="设备角色:子设备"
android:textSize="22sp"
android:background="@android:color/transparent" />
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/btnScaleMonitor"
android:layout_width="0dp"
android:layout_height="70dp"
android:layout_weight="1"
android:text="秤数据监控"
android:textSize="26sp"
android:visibility="gone"
android:background="@android:color/transparent" />
</LinearLayout>
<!-- Fragment 容器 -->
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="12dp"
tools:background="@color/bg_color">
<!-- 本机IP地址 -->
<TextView
android:id="@+id/tvDeviceIp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="0dp"
android:textSize="22sp"
android:textStyle="bold"
android:textColor="@color/home_title"
tools:text="IP192.168.1.100" />
<!-- 设备信息 -->
<TextView
android:id="@+id/tvDeviceId"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="0dp"
android:textSize="18sp"
android:textColor="@color/home_sub_title"
tools:text="设备:device_02" />
<!-- 主设备连接状态 -->
<TextView
android:id="@+id/tvMasterStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="0dp"
android:layout_marginBottom="12dp"
android:textSize="16sp"
tools:text="主设备:未连接" />
<!-- 本机秤数据列表 -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvScaleList"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:overScrollMode="never" />
</LinearLayout>
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 设备卡片:竖向排列,显示设备号、IP、以及该设备下所有秤数据 -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="12dp"
android:paddingBottom="12dp">
<!-- 设备 IP -->
<TextView
android:id="@+id/tvDeviceIp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="20sp"
android:textStyle="bold"
android:textColor="@color/home_title"
tools:text="IP192.168.1.100" />
<!-- 设备号 -->
<TextView
android:id="@+id/tvDeviceLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/home_sub_title"
android:textSize="14sp"
tools:text="设备:device_01" />
<!-- 该设备下所有秤数据行,由代码动态填充 -->
<LinearLayout
android:id="@+id/llScaleContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginTop="5dp" />
</LinearLayout>
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 单行秤数据:秤编号 + 重量 + 状态 -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="8dp"
android:paddingBottom="8dp">
<!-- 秤编号 -->
<TextView
android:id="@+id/tvScaleLabel"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="22sp"
android:textColor="@color/home_sub_title"
tools:text="秤 1" />
<!-- 重量 -->
<TextView
android:id="@+id/tvWeight"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="2"
android:gravity="center"
android:textSize="26sp"
android:textStyle="bold"
android:textColor="@color/home_title"
tools:text="235.5 g" />
<!-- 状态 -->
<TextView
android:id="@+id/tvState"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="end"
android:textSize="18sp"
android:textColor="@color/home_sub_title"
tools:text="稳定" />
</LinearLayout>
+2
View File
@@ -30,6 +30,7 @@ refreshLayoutKernel = "3.0.0-alpha"
refreshHeaderClassics = "3.0.0-alpha"
refreshHeaderRadar = "3.0.0-alpha"
roomRuntime = "2.5.2"
java-websocket = "1.5.7"
[libraries]
android-core = { module = "com.google.zxing:android-core", version.ref = "androidCore" }
@@ -75,6 +76,7 @@ refresh-header-radar = { module = "io.github.scwang90:refresh-header-radar", ver
refresh-header-classics = { module = "io.github.scwang90:refresh-header-classics", version.ref = "refreshHeaderClassics" }
refresh-layout-kernel = { module = "io.github.scwang90:refresh-layout-kernel", version.ref = "refreshLayoutKernel" }
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "converterGson" }
java-websocket = { module = "org.java-websocket:Java-WebSocket", version.ref = "java-websocket" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }