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:
2026-04-08 17:15:48 +08:00
co-authored by Claude Sonnet 4.6
parent d6a9c3562d
commit 29228b069b
25 changed files with 1398 additions and 9 deletions
@@ -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
}
}
}