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:
@@ -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,104 @@
|
||||
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 地址
|
||||
* @param deviceId 设备 ID
|
||||
* @param ip IP 地址字符串
|
||||
*/
|
||||
fun setDeviceIp(deviceId: String, ip: String) {
|
||||
deviceIpMap[deviceId] = ip
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动本机秤数据监听
|
||||
* 主设备本机秤直接读取 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,140 @@
|
||||
package com.shuwei.dish.match.scale
|
||||
|
||||
import android.content.Context
|
||||
import android.net.wifi.WifiManager
|
||||
import android.util.Log
|
||||
import com.shuwei.dish.match.base.DeviceRole
|
||||
import com.shuwei.dish.match.base.GlobalData
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 WifiManager 获取本机 WiFi IP 地址
|
||||
*/
|
||||
private fun getLocalIp(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) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动主设备专属服务
|
||||
*/
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user