diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index a3a5596..34fb441 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 0b8c797..a7fc2ef 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -93,6 +93,14 @@
android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity" />
+
+
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
+ }
+}
diff --git a/app/src/main/java/com/shuwei/dish/match/scale/MdnsRegisterManager.kt b/app/src/main/java/com/shuwei/dish/match/scale/MdnsRegisterManager.kt
new file mode 100644
index 0000000..74dbacc
--- /dev/null
+++ b/app/src/main/java/com/shuwei/dish/match/scale/MdnsRegisterManager.kt
@@ -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
+ }
+}
diff --git a/app/src/main/java/com/shuwei/dish/match/scale/ScaleData.kt b/app/src/main/java/com/shuwei/dish/match/scale/ScaleData.kt
new file mode 100644
index 0000000..384441e
--- /dev/null
+++ b/app/src/main/java/com/shuwei/dish/match/scale/ScaleData.kt
@@ -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 = ""
+)
diff --git a/app/src/main/java/com/shuwei/dish/match/scale/ScaleDataAggregator.kt b/app/src/main/java/com/shuwei/dish/match/scale/ScaleDataAggregator.kt
new file mode 100644
index 0000000..c091e8b
--- /dev/null
+++ b/app/src/main/java/com/shuwei/dish/match/scale/ScaleDataAggregator.kt
@@ -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