From 29228b069bf06fb0266bd7eb546ad562638c37de Mon Sep 17 00:00:00 2001 From: lvmeng <848755140@qq.com> Date: Wed, 8 Apr 2026 17:15:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(scale):=20=E6=96=B0=E5=A2=9E=E4=B8=BB?= =?UTF-8?q?=E4=BB=8E=E8=AE=BE=E5=A4=87=E7=A7=A4=E6=95=B0=E6=8D=AE=E8=81=94?= =?UTF-8?q?=E7=BD=91=E5=90=8C=E6=AD=A5=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 8 + .../shuwei/dish/match/base/BaseActivity.kt | 43 ++++- .../com/shuwei/dish/match/base/BaseApp.kt | 17 ++ .../com/shuwei/dish/match/base/GlobalData.kt | 22 ++- .../dish/match/scale/MdnsDiscoveryManager.kt | 116 ++++++++++++ .../dish/match/scale/MdnsRegisterManager.kt | 80 +++++++++ .../com/shuwei/dish/match/scale/ScaleData.kt | 19 ++ .../dish/match/scale/ScaleDataAggregator.kt | 104 +++++++++++ .../dish/match/scale/ScaleServiceManager.kt | 140 +++++++++++++++ .../dish/match/scale/ScaleWebSocketClient.kt | 145 +++++++++++++++ .../dish/match/scale/ScaleWebSocketServer.kt | 134 ++++++++++++++ .../com/shuwei/dish/match/ui/InitActivity.kt | 25 ++- .../dish/match/ui/MasterScaleActivity.kt | 167 ++++++++++++++++++ .../shuwei/dish/match/ui/SettingActivity.kt | 22 +++ .../com/shuwei/dish/match/ui/SlaveActivity.kt | 144 +++++++++++++++ .../dish/match/ui/fragment/CollectFragment.kt | 6 + .../com/shuwei/dish/match/utils/SpTool.kt | 4 + .../com/shuwei/dish/match/utils/Weigher2.java | 10 ++ .../main/res/layout/activity_master_scale.xml | 41 +++++ app/src/main/res/layout/activity_setting.xml | 19 ++ app/src/main/res/layout/activity_slave.xml | 52 ++++++ .../main/res/layout/list_item_scale_data.xml | 40 +++++ .../main/res/layout/list_item_scale_row.xml | 46 +++++ gradle/libs.versions.toml | 2 + 25 files changed, 1398 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/com/shuwei/dish/match/scale/MdnsDiscoveryManager.kt create mode 100644 app/src/main/java/com/shuwei/dish/match/scale/MdnsRegisterManager.kt create mode 100644 app/src/main/java/com/shuwei/dish/match/scale/ScaleData.kt create mode 100644 app/src/main/java/com/shuwei/dish/match/scale/ScaleDataAggregator.kt create mode 100644 app/src/main/java/com/shuwei/dish/match/scale/ScaleServiceManager.kt create mode 100644 app/src/main/java/com/shuwei/dish/match/scale/ScaleWebSocketClient.kt create mode 100644 app/src/main/java/com/shuwei/dish/match/scale/ScaleWebSocketServer.kt create mode 100644 app/src/main/java/com/shuwei/dish/match/ui/MasterScaleActivity.kt create mode 100644 app/src/main/java/com/shuwei/dish/match/ui/SlaveActivity.kt create mode 100644 app/src/main/res/layout/activity_master_scale.xml create mode 100644 app/src/main/res/layout/activity_slave.xml create mode 100644 app/src/main/res/layout/list_item_scale_data.xml create mode 100644 app/src/main/res/layout/list_item_scale_row.xml 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>(emptyMap()) + val allScales: StateFlow> = _allScales.asStateFlow() + + /** 内部缓存,并发安全 */ + private val cache = ConcurrentHashMap() + + /** deviceId → IP 地址映射,用于写入 ScaleData.ip 字段 */ + private val deviceIpMap = ConcurrentHashMap() + + /** + * 设置指定设备的 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) + } +} diff --git a/app/src/main/java/com/shuwei/dish/match/scale/ScaleServiceManager.kt b/app/src/main/java/com/shuwei/dish/match/scale/ScaleServiceManager.kt new file mode 100644 index 0000000..2d9f731 --- /dev/null +++ b/app/src/main/java/com/shuwei/dish/match/scale/ScaleServiceManager.kt @@ -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>? + 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, "所有秤服务已停止") + } +} diff --git a/app/src/main/java/com/shuwei/dish/match/scale/ScaleWebSocketClient.kt b/app/src/main/java/com/shuwei/dish/match/scale/ScaleWebSocketClient.kt new file mode 100644 index 0000000..89eed4a --- /dev/null +++ b/app/src/main/java/com/shuwei/dish/match/scale/ScaleWebSocketClient.kt @@ -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() + /** 各设备当前重连任务,key = deviceId */ + private val reconnectTasks = ConcurrentHashMap>() + /** 各设备当前重连延迟,key = deviceId */ + private val reconnectDelays = ConcurrentHashMap() + /** 已主动移除的设备集合,不对其触发重连 */ + private val removedDevices = ConcurrentHashMap.newKeySet() + + 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 已关闭") + } +} diff --git a/app/src/main/java/com/shuwei/dish/match/scale/ScaleWebSocketServer.kt b/app/src/main/java/com/shuwei/dish/match/scale/ScaleWebSocketServer.kt new file mode 100644 index 0000000..2cb85ab --- /dev/null +++ b/app/src/main/java/com/shuwei/dish/match/scale/ScaleWebSocketServer.kt @@ -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() + + /** 缓存各秤最新数据,新客户端连接时立即同步当前状态 */ + private val latestData = mutableMapOf() + + /** + * 启动 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 + } + } +} diff --git a/app/src/main/java/com/shuwei/dish/match/ui/InitActivity.kt b/app/src/main/java/com/shuwei/dish/match/ui/InitActivity.kt index 386ac90..cc5bfe7 100644 --- a/app/src/main/java/com/shuwei/dish/match/ui/InitActivity.kt +++ b/app/src/main/java/com/shuwei/dish/match/ui/InitActivity.kt @@ -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() + return + } + // 主设备:走原有路由逻辑 val launchPageType = SpTool.getInt(SpTool.LAUNCH_PAGE_TYPE, -1) when (launchPageType) { 0 -> { diff --git a/app/src/main/java/com/shuwei/dish/match/ui/MasterScaleActivity.kt b/app/src/main/java/com/shuwei/dish/match/ui/MasterScaleActivity.kt new file mode 100644 index 0000000..5b398e8 --- /dev/null +++ b/app/src/main/java/com/shuwei/dish/match/ui/MasterScaleActivity.kt @@ -0,0 +1,167 @@ +package com.shuwei.dish.match.ui + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.LinearLayout +import android.widget.TextView +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.shuwei.dish.match.base.BaseActivity +import com.shuwei.dish.match.databinding.ActivityMasterScaleBinding +import com.shuwei.dish.match.databinding.ListItemScaleDataBinding +import com.shuwei.dish.match.scale.ScaleData +import com.shuwei.dish.match.scale.ScaleServiceManager +import com.shuwei.dish.match.utils.WeightUtil +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +/** + * 主设备秤数据总览页面(仅 MASTER 角色显示) + * 聚合展示所有设备(本机 + 远端子设备)的实时秤数据 + * 数据按设备分组展示:设备号 + IP + 该设备下所有秤数据 + * 数据来源:ScaleServiceManager.allScales(StateFlow) + */ +class MasterScaleActivity : BaseActivity() { + + private lateinit var binding: ActivityMasterScaleBinding + + /** + * 按设备分组后的数据模型 + * @param deviceId 设备 ID + * @param ip 设备 IP 地址 + * @param scales 该设备下所有秤数据,按 address 排序 + */ + private data class DeviceGroup( + val deviceId: String, + val ip: String, + val scales: List + ) + + private val groupList = mutableListOf() + private val adapter = DeviceGroupAdapter(groupList) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityMasterScaleBinding.inflate(layoutInflater) + setContentView(binding.root) + setHeaderBackground() + + binding.rvScaleList.layoutManager = LinearLayoutManager(this) + binding.rvScaleList.adapter = adapter + + observeScaleData() + } + + /** + * 订阅 ScaleServiceManager 的全量秤数据 StateFlow + * 每次有任一秤数据更新时,重新按设备分组并刷新列表 + */ + private fun observeScaleData() { + val flow = ScaleServiceManager.allScales ?: return + + lifecycleScope.launch { + flow.collectLatest { scaleMap -> + // 按 deviceId 分组,本机设备排最前,其余按 deviceId 排序 + val localId = com.shuwei.dish.match.base.GlobalData.deviceId + val groups = scaleMap.values + .groupBy { it.deviceId } + .entries + .sortedWith(compareBy({ if (it.key == localId) 0 else 1 }, { it.key })) + .map { (deviceId, scales) -> + DeviceGroup( + deviceId = deviceId, + ip = scales.firstOrNull()?.ip ?: "", + scales = scales.sortedBy { it.address } + ) + } + updateList(groups) + updateStatusBar(scaleMap) + } + } + } + + @SuppressLint("NotifyDataSetChanged") + private fun updateList(groups: List) { + groupList.clear() + groupList.addAll(groups) + adapter.notifyDataSetChanged() + } + + @SuppressLint("SetTextI18n") + private fun updateStatusBar(scaleMap: Map) { + 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) : + RecyclerView.Adapter() { + + 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() +} + diff --git a/app/src/main/java/com/shuwei/dish/match/ui/SettingActivity.kt b/app/src/main/java/com/shuwei/dish/match/ui/SettingActivity.kt index ccd073b..3768583 100644 --- a/app/src/main/java/com/shuwei/dish/match/ui/SettingActivity.kt +++ b/app/src/main/java/com/shuwei/dish/match/ui/SettingActivity.kt @@ -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() + } + } + // 初始化按钮颜色(默认设备配置为选中状态) updateButtonColors(isDeviceConfigSelected = true) } + /** 刷新角色按钮文字 */ + private fun updateRoleButtonText() { + val roleLabel = if (GlobalData.deviceRole == DeviceRole.MASTER) "主设备" else "子设备" + binding.btnDeviceRole.text = "设备角色:$roleLabel" + } + /** * 更新底部菜单按钮的颜色状态 * @param isDeviceConfigSelected 设备配置按钮是否被选中 diff --git a/app/src/main/java/com/shuwei/dish/match/ui/SlaveActivity.kt b/app/src/main/java/com/shuwei/dish/match/ui/SlaveActivity.kt new file mode 100644 index 0000000..7e28133 --- /dev/null +++ b/app/src/main/java/com/shuwei/dish/match/ui/SlaveActivity.kt @@ -0,0 +1,144 @@ +package com.shuwei.dish.match.ui + +import android.annotation.SuppressLint +import android.net.wifi.WifiManager +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.shuwei.dish.match.base.BaseActivity +import com.shuwei.dish.match.base.GlobalData +import com.shuwei.dish.match.databinding.ActivitySlaveBinding +import com.shuwei.dish.match.databinding.ListItemScaleRowBinding +import com.shuwei.dish.match.scale.ScaleServiceManager +import com.shuwei.dish.match.utils.WeightUtil + +/** + * 子设备专属页面(小屏幕设备使用) + * 仅展示本机秤的实时数据和主设备连接状态 + * 布局简洁,适配小屏幕 + */ +class SlaveActivity : BaseActivity() { + + private lateinit var binding: ActivitySlaveBinding + + /** 本机秤数据列表:address → (weight, state) */ + private data class ScaleItem(val address: Int, val weight: Double, val state: Int) + + private val scaleList = mutableListOf() + private val adapter = ScaleAdapter(scaleList) + + /** 已连接的主设备数量(有连接即代表主设备在线) */ + private var masterConnected = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivitySlaveBinding.inflate(layoutInflater) + setContentView(binding.root) + setHeaderBackground() + + binding.tvDeviceId.text = "设备:${GlobalData.deviceId}" + binding.tvDeviceIp.text = "IP:${getLocalIpAddress()}" + binding.rvScaleList.layoutManager = LinearLayoutManager(this) + binding.rvScaleList.adapter = adapter + + updateMasterStatus(false) + listenLocalScales() + listenMasterConnection() + } + + /** + * 订阅主设备连接状态,回调在非主线程,切换到主线程更新 UI + */ + private fun listenMasterConnection() { + ScaleServiceManager.onMasterConnectionChanged = { connected -> + runOnUiThread { updateMasterStatus(connected) } + } + } + + /** + * 监听本机 WeightUtil 回调,实时刷新秤列表 + */ + private fun listenLocalScales() { + WeightUtil.addWeightListener(TAG) { address, state, weight -> + runOnUiThread { + val idx = scaleList.indexOfFirst { it.address == address } + val item = ScaleItem(address, weight, state) + if (idx >= 0) { + scaleList[idx] = item + adapter.notifyItemChanged(idx) + } else { + scaleList.add(item) + scaleList.sortBy { it.address } + adapter.notifyDataSetChanged() + } + } + } + } + + /** + * 更新主设备连接状态文字 + * 由 ScaleWebSocketServer 的 onOpen/onClose 回调驱动(可在后续扩展中接入) + */ + @SuppressLint("SetTextI18n") + fun updateMasterStatus(connected: Boolean) { + masterConnected = connected + binding.tvMasterStatus.text = if (connected) "主设备:已连接 ✓" else "主设备:未连接" + binding.tvMasterStatus.setTextColor( + getColor(if (connected) android.R.color.holo_green_dark else android.R.color.holo_red_light) + ) + } + + override fun onDestroy() { + ScaleServiceManager.onMasterConnectionChanged = null + WeightUtil.removeWeightListener(TAG) + super.onDestroy() + } + + companion object { + const val TAG = "SlaveActivity" + } + + /** + * 获取本机 WiFi IP 地址 + * 优先使用 WifiManager,获取失败时返回"未知" + */ + @SuppressLint("WifiManagerLeak") + private fun getLocalIpAddress(): String { + return try { + val wifiManager = applicationContext.getSystemService(WIFI_SERVICE) as WifiManager + val ip = wifiManager.connectionInfo.ipAddress + if (ip == 0) "未知" else + "${ip and 0xFF}.${ip shr 8 and 0xFF}.${ip shr 16 and 0xFF}.${ip shr 24 and 0xFF}" + } catch (e: Exception) { + "未知" + } + } + + private class ScaleAdapter(private val data: List) : + RecyclerView.Adapter() { + + 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 + } + } +} diff --git a/app/src/main/java/com/shuwei/dish/match/ui/fragment/CollectFragment.kt b/app/src/main/java/com/shuwei/dish/match/ui/fragment/CollectFragment.kt index 2bc5621..25177cc 100644 --- a/app/src/main/java/com/shuwei/dish/match/ui/fragment/CollectFragment.kt +++ b/app/src/main/java/com/shuwei/dish/match/ui/fragment/CollectFragment.kt @@ -161,6 +161,12 @@ class CollectFragment : BaseFragment() { 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() diff --git a/app/src/main/java/com/shuwei/dish/match/utils/SpTool.kt b/app/src/main/java/com/shuwei/dish/match/utils/SpTool.kt index 00bc8d3..28d0f0f 100644 --- a/app/src/main/java/com/shuwei/dish/match/utils/SpTool.kt +++ b/app/src/main/java/com/shuwei/dish/match/utils/SpTool.kt @@ -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()!! diff --git a/app/src/main/java/com/shuwei/dish/match/utils/Weigher2.java b/app/src/main/java/com/shuwei/dish/match/utils/Weigher2.java index 77aa436..3c1c8c0 100644 --- a/app/src/main/java/com/shuwei/dish/match/utils/Weigher2.java +++ b/app/src/main/java/com/shuwei/dish/match/utils/Weigher2.java @@ -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") { diff --git a/app/src/main/res/layout/activity_master_scale.xml b/app/src/main/res/layout/activity_master_scale.xml new file mode 100644 index 0000000..6373dce --- /dev/null +++ b/app/src/main/res/layout/activity_master_scale.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_setting.xml b/app/src/main/res/layout/activity_setting.xml index d0643b1..d1d36cb 100644 --- a/app/src/main/res/layout/activity_setting.xml +++ b/app/src/main/res/layout/activity_setting.xml @@ -37,6 +37,25 @@ android:textSize="26sp" android:background="@android:color/transparent" /> + + + + diff --git a/app/src/main/res/layout/activity_slave.xml b/app/src/main/res/layout/activity_slave.xml new file mode 100644 index 0000000..3fe55c9 --- /dev/null +++ b/app/src/main/res/layout/activity_slave.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/list_item_scale_data.xml b/app/src/main/res/layout/list_item_scale_data.xml new file mode 100644 index 0000000..9fbaefe --- /dev/null +++ b/app/src/main/res/layout/list_item_scale_data.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/list_item_scale_row.xml b/app/src/main/res/layout/list_item_scale_row.xml new file mode 100644 index 0000000..4fc9caf --- /dev/null +++ b/app/src/main/res/layout/list_item_scale_row.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2a27141..3b73c9c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }