feat(mqtt): 增加人脸库 MQTT 实时变更同步功能
- 新增 MQTT 客户端依赖及配置,实现设备端实时接收人脸变化推送 - 实现 FaceMqttSubscriber 单例,启动时解析环境选择对应 MQTT 配置 - 实现消息解析、乱序过滤及本地人脸库数据实时更新逻辑 - 设备端订阅人脸变更主题,连接/重连成功触发 HTTP 增量补拉兜底机制 - 在 LoginByFaceActivity 中订阅 MQTT 事件,实时刷新识别引擎内存 - MQTT 连接管理器 MqttManager 负责连接、重连、订阅及消息分发,实现完整生命周期管理 - 新增环境选择弹窗,优化初始化界面布局和按钮样式 - 配置激活码硬编码修正,避免测试环境激活码错误导致的问题 - 日志与错误提示等细节优化,提升稳定性与用户体验
This commit is contained in:
@@ -100,4 +100,7 @@ dependencies {
|
||||
implementation(libs.android.core)
|
||||
|
||||
implementation("org.greenrobot:eventbus:3.3.1")
|
||||
|
||||
// MQTT 客户端(人脸变更实时推送)
|
||||
implementation(libs.paho.mqtt)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.sw.platecabinet
|
||||
import android.util.Log
|
||||
import com.sw.plate.App
|
||||
import com.sw.plate.utils.AppUtil
|
||||
import com.sw.platecabinet.mqtt.FaceMqttSubscriber
|
||||
import com.sw.platecabinet.utils.CrashHandler
|
||||
import com.sw.platecabinet.utils.SpTool
|
||||
import timber.log.Timber
|
||||
@@ -20,6 +21,9 @@ class MyApp : App() {
|
||||
|
||||
// 初始化崩溃处理器
|
||||
CrashHandler.init(this)
|
||||
|
||||
// 启动人脸 MQTT 实时订阅(进程级单例,应用生命周期内保持连接)
|
||||
FaceMqttSubscriber.start()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -48,7 +48,9 @@ class DeviceInitActivity : BaseActivity<ActivityDeviceInitBinding>() {
|
||||
}
|
||||
GlobalData.appId = deviceConfig.arcsoftAppId ?: ""
|
||||
GlobalData.sdkKey = deviceConfig.arcsoftSdkKey ?: ""
|
||||
GlobalData.activeKey = deviceConfig.arcsoftActiveKey ?: ""
|
||||
// 测试设备后台下发的激活码不正确,手动硬编码覆盖为正确值(后台修复后可回退为下行)
|
||||
// GlobalData.activeKey = deviceConfig.arcsoftActiveKey ?: ""
|
||||
GlobalData.activeKey = "085F-118G-Q4V1-THBP"
|
||||
hideWaitingDialog()
|
||||
goLoginActivity()
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ import com.sw.platecabinet.member.R
|
||||
import com.sw.platecabinet.member.databinding.ActivityLoginFaceBinding
|
||||
import com.sw.platecabinet.member.databinding.ItemTitleTimeBinding
|
||||
import com.sw.platecabinet.model.response.EquipmentUserInfo
|
||||
import com.sw.platecabinet.mqtt.FaceChangedEvent
|
||||
import com.sw.platecabinet.mqtt.FaceSyncTriggerEvent
|
||||
import com.sw.platecabinet.network.task.TaskManager
|
||||
import com.sw.platecabinet.utils.PermissionHelper
|
||||
import org.greenrobot.eventbus.EventBus
|
||||
@@ -143,6 +145,35 @@ class LoginByFaceActivity : BaseActivity<ActivityLoginFaceBinding>(),
|
||||
Timber.tag("performSync over").e("-time=%s", insertEntity.registerTime)
|
||||
}
|
||||
|
||||
/**
|
||||
* MQTT 实时同步后的人脸库已落库,防抖重载识别引擎内存(合并短时间内连续变更)
|
||||
*/
|
||||
private val faceRefreshHandler = Handler(Looper.getMainLooper())
|
||||
private val faceRefreshRunnable = Runnable { recognizeViewModel.refreshFaceList() }
|
||||
|
||||
@Subscribe(threadMode = ThreadMode.MAIN)
|
||||
fun onFaceChanged(event: FaceChangedEvent) {
|
||||
Timber.d("onFaceChanged 收到人脸实时变更,2s 后刷新引擎内存")
|
||||
faceRefreshHandler.removeCallbacks(faceRefreshRunnable)
|
||||
faceRefreshHandler.postDelayed(faceRefreshRunnable, 2000)
|
||||
}
|
||||
|
||||
/**
|
||||
* MQTT 连接/重连成功,补拉一次 HTTP 增量兜底(设备离线期间漏收的变更)
|
||||
*/
|
||||
@Subscribe(threadMode = ThreadMode.MAIN)
|
||||
fun onFaceSyncTrigger(event: FaceSyncTriggerEvent) {
|
||||
val timestamp = SpTool.getLastFaceTimestamp()
|
||||
if (timestamp == 0L) {
|
||||
Timber.d("onFaceSyncTrigger 水位为 0,跳过增量补拉")
|
||||
return
|
||||
}
|
||||
Timber.d("onFaceSyncTrigger MQTT 已连接,执行 HTTP 增量补拉 timestamp=$timestamp")
|
||||
netViewModelV2.getFaceIncrementList(pageNo = 1, timestamp = timestamp) {
|
||||
recognizeViewModel.refreshFaceList()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLeftDoubleClick() {
|
||||
finish()
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@ import android.content.res.ColorStateList
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.os.Bundle
|
||||
import android.text.SpannableString
|
||||
import android.text.Spanned
|
||||
import android.text.style.AbsoluteSizeSpan
|
||||
import android.text.style.ForegroundColorSpan
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.Window
|
||||
@@ -50,9 +54,8 @@ class EnvironmentSelectDialog(
|
||||
ENVIRONMENTS.forEach { env ->
|
||||
val rb = RadioButton(activity).apply {
|
||||
id = View.generateViewId()
|
||||
text = env.name
|
||||
text = buildEnvironmentLabel(env.name, env.url)
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
textSize = 18f
|
||||
setTextColor(Color.WHITE)
|
||||
buttonTintList = ColorStateList.valueOf(Color.WHITE)
|
||||
isChecked = env.url == current
|
||||
@@ -69,6 +72,19 @@ class EnvironmentSelectDialog(
|
||||
selected = ENVIRONMENTS.firstOrNull { it.url == current }
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造环境选项文本:名称(白色 18sp)+ 换行 + 地址(灰色 13sp)
|
||||
*/
|
||||
private fun buildEnvironmentLabel(name: String, url: String): SpannableString {
|
||||
val label = "$name\n$url"
|
||||
val spannable = SpannableString(label)
|
||||
val urlStart = name.length + 1 // 跳过换行符
|
||||
spannable.setSpan(AbsoluteSizeSpan(18, true), 0, name.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
spannable.setSpan(AbsoluteSizeSpan(13, true), urlStart, label.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
spannable.setSpan(ForegroundColorSpan(Color.GRAY), urlStart, label.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
return spannable
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认按钮:回调所选环境并关闭弹窗
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.sw.platecabinet.mqtt
|
||||
|
||||
/** 人脸库已由 MQTT 实时更新(DB 已落库),通知界面层刷新识别引擎内存 */
|
||||
class FaceChangedEvent
|
||||
|
||||
/** MQTT 连接/重连成功,请求执行一次 HTTP 增量补拉兜底 */
|
||||
class FaceSyncTriggerEvent
|
||||
@@ -0,0 +1,281 @@
|
||||
package com.sw.platecabinet.mqtt
|
||||
|
||||
import com.google.gson.JsonParser
|
||||
import com.sw.plate.utils.arcface.FaceApi
|
||||
import com.sw.plate.utils.arcface.facedb.entity.FaceEntity
|
||||
import com.sw.platecabinet.GlobalData
|
||||
import com.sw.platecabinet.model.response.UserFaceModelV2
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import org.greenrobot.eventbus.EventBus
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* 人脸 MQTT 实时同步订阅器(进程级单例,替代 Hilt 注入)
|
||||
*
|
||||
* 订阅服务端人脸变更广播主题(随环境区分):用户在小程序/管理端采集或更换人脸后,
|
||||
* 服务端实时推送特征码变更,设备端立即更新本地人脸库,无需等待 5 分钟定时增量轮询。
|
||||
*
|
||||
* 与增量接口的联动:MQTT 提供秒级实时推送;设备侧另有 5 分钟定时轮询 +
|
||||
* 连接/重连成功时补拉一次 HTTP 增量,兜底推送失败与设备离线
|
||||
* (关机/断网/broker 会话过期)漏收的变更。增量水位只由 HTTP 响应推进,
|
||||
* 本模块不改动水位。
|
||||
*
|
||||
* 幂等与乱序按 userFaceId + 时间戳守卫处理(见 [applyRealtimeUpdates])。
|
||||
*/
|
||||
object FaceMqttSubscriber {
|
||||
|
||||
private const val TAG = "FaceMqttSubscriber"
|
||||
|
||||
/** MQTT 账号(dev 域名与测试 IP 共用) */
|
||||
private const val MQTT_USER = "platform"
|
||||
private const val MQTT_PASSWORD = "ZrKhZhng6t2tlpid"
|
||||
|
||||
/** MQTT 账号(UAT 独立) */
|
||||
private const val MQTT_USER_UAT = "platform-uat"
|
||||
private const val MQTT_PASSWORD_UAT = "p2AXu3lsUllbXJEY_A1!"
|
||||
|
||||
/**
|
||||
* 根据业务服务器 BASE_URL 解析对应的 MQTT 环境配置(host、port、协议、订阅主题)。
|
||||
* 与当前项目的三个预设环境对齐:本地 / 测试 / 生产(UAT)。
|
||||
* 未匹配的环境返回 null 表示不接入实时同步。
|
||||
*/
|
||||
private fun resolveMqttEnv(baseUrl: String): MqttEnv? = when {
|
||||
baseUrl.startsWith("https://platform-api.uat.shuziweidao.com") ->
|
||||
MqttEnv("mqtt.uat.shuziweidao.com", 443, "wss", "yx/device/face/update-local",
|
||||
MQTT_USER_UAT, MQTT_PASSWORD_UAT)
|
||||
baseUrl.startsWith("https://dev.yixiong-tech.com") ->
|
||||
MqttEnv("dev.yixiong-tech.com", 8089, "wss", "yx/device/face/update-test",
|
||||
MQTT_USER, MQTT_PASSWORD)
|
||||
baseUrl.startsWith("http://192.168.10.101") ->
|
||||
MqttEnv("192.168.10.101", 1884, "tcp", "yx/device/face/update-dev",
|
||||
MQTT_USER, MQTT_PASSWORD)
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** MQTT 环境配置:host、port、协议、订阅主题、账号 */
|
||||
private data class MqttEnv(
|
||||
val host: String,
|
||||
val port: Int,
|
||||
val scheme: String,
|
||||
val topic: String,
|
||||
val userName: String,
|
||||
val password: String
|
||||
)
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val faceApi = FaceApi()
|
||||
|
||||
private var started = false
|
||||
private var collectJob: Job? = null
|
||||
private var stateJob: Job? = null
|
||||
|
||||
/** 当前环境的人脸变更订阅主题(随 start() 按 baseUrl 环境解析) */
|
||||
private var faceUpdateTopic: String = ""
|
||||
|
||||
/**
|
||||
* 启动订阅(幂等,重复调用不会建立多条连接)
|
||||
*
|
||||
* clientId 使用 `platecabinet-{设备UDID}`,与 X-DEVICE-CODE 同源,保证一台设备一条连接。
|
||||
* 应在应用启动、设备初始化完成后调用。
|
||||
*/
|
||||
fun start() {
|
||||
if (started) return
|
||||
started = true
|
||||
|
||||
val baseUrl = GlobalData.appBaseUrl
|
||||
val env = resolveMqttEnv(baseUrl) ?: run {
|
||||
started = false
|
||||
Timber.w("$TAG 当前环境(baseUrl=$baseUrl)无 MQTT 配置,跳过实时同步,人脸变更仅靠定时增量轮询兜底")
|
||||
return
|
||||
}
|
||||
faceUpdateTopic = env.topic
|
||||
val udid = GlobalData.deviceId.ifBlank { "unknown" }
|
||||
Timber.i(
|
||||
"$TAG 启动人脸 MQTT 订阅, broker=${env.scheme}://${env.host}:${env.port}, " +
|
||||
"topic=${env.topic}, clientId=platecabinet-$udid"
|
||||
)
|
||||
|
||||
MqttManager.configure(
|
||||
MqttConfig(
|
||||
host = env.host,
|
||||
port = env.port,
|
||||
scheme = env.scheme,
|
||||
clientId = "platecabinet-$udid",
|
||||
userName = env.userName,
|
||||
password = env.password,
|
||||
cleanSession = false
|
||||
)
|
||||
)
|
||||
MqttManager.connect()
|
||||
|
||||
// 消费广播消息:解析 → 落库 → 通知界面刷新引擎内存
|
||||
collectJob = scope.launch {
|
||||
MqttManager.messages.collect { message ->
|
||||
if (message is MqttMessage.Received) {
|
||||
if (message.topic == faceUpdateTopic) {
|
||||
handleFaceUpdate(message.payload)
|
||||
} else {
|
||||
// 联调排查:后台发送的 topic 与预期不一致时在此可见
|
||||
Timber.w("$TAG 收到非人脸主题消息,忽略: topic=${message.topic} size=${message.payload.size}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 连接状态联动:连接成功 → 恢复订阅 + 触发一次 HTTP 增量补拉
|
||||
stateJob = scope.launch {
|
||||
MqttManager.state.collect { state ->
|
||||
if (state == MqttState.Connected) {
|
||||
MqttManager.subscribe(faceUpdateTopic, 1)
|
||||
// 设备离线期间(关机/断网/会话过期)的变更 MQTT 无法补收,
|
||||
// 通知界面层用水位时间戳补拉一次 HTTP 增量兜底
|
||||
EventBus.getDefault().post(FaceSyncTriggerEvent())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
if (!started) return
|
||||
started = false
|
||||
collectJob?.cancel()
|
||||
stateJob?.cancel()
|
||||
collectJob = null
|
||||
stateJob = null
|
||||
MqttManager.disconnect()
|
||||
}
|
||||
|
||||
/** 解析广播 JSON 数组并落库(faceUpdateTimestamp 兼容 long/String 两种格式) */
|
||||
private fun handleFaceUpdate(payload: ByteArray) {
|
||||
// 联调排查:记录原始报文预览(特征码很长,只打前 120 字符)
|
||||
val raw = String(payload, Charsets.UTF_8)
|
||||
Timber.i("$TAG 收到人脸广播: size=${payload.size} 预览=${raw.take(120)}")
|
||||
val items = try {
|
||||
parsePayload(raw)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "$TAG 广播消息解析失败: ${raw.take(300)}")
|
||||
return
|
||||
}
|
||||
// 联调排查:逐条记录关键字段(最多 5 条,特征码只打长度)
|
||||
items.take(5).forEach {
|
||||
Timber.i(
|
||||
"$TAG 解析条目: userFaceId=${it.userFaceId} userId=${it.userId} " +
|
||||
"deleted=${it.faceDeleted} ts=${it.faceUpdateTimestamp} " +
|
||||
"featureLen=${it.resolveFeatureStr()?.length ?: 0}"
|
||||
)
|
||||
}
|
||||
Timber.i("$TAG 收到人脸广播 ${items.size} 条,开始落库")
|
||||
applyRealtimeUpdates(items)
|
||||
Timber.i("$TAG 人脸广播处理完成 ${items.size} 条")
|
||||
}
|
||||
|
||||
/** 解析 JSON 数组为 [UserFaceModelV2](时间戳兼容 long 数字与字符串两种格式) */
|
||||
private fun parsePayload(raw: String): List<UserFaceModelV2> {
|
||||
val array = JsonParser.parseString(raw).asJsonArray
|
||||
return array.mapNotNull { element ->
|
||||
val obj = element.asJsonObject
|
||||
val tsElement = obj.get("faceUpdateTimestamp")
|
||||
val timestamp = when {
|
||||
tsElement == null || tsElement.isJsonNull -> null
|
||||
tsElement.isJsonPrimitive -> tsElement.asString.toLongOrNull()
|
||||
else -> null
|
||||
}
|
||||
UserFaceModelV2(
|
||||
userFaceId = obj.get("userFaceId")?.takeIf { !it.isJsonNull }?.asString,
|
||||
userId = obj.get("userId")?.takeIf { !it.isJsonNull }?.asString,
|
||||
faceFeature = obj.get("faceFeature")?.takeIf { !it.isJsonNull }?.asString,
|
||||
faceFeatureStr = null,
|
||||
faceFeatureString = null,
|
||||
faceUpdateTimestamp = timestamp,
|
||||
cardNo = obj.get("cardNo")?.takeIf { !it.isJsonNull }?.asString,
|
||||
member = obj.get("member")?.takeIf { !it.isJsonNull }?.asBoolean,
|
||||
faceDeleted = obj.get("faceDeleted")?.takeIf { !it.isJsonNull }?.asBoolean ?: false,
|
||||
personType = obj.get("personType")?.takeIf { !it.isJsonNull }?.asString
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用 MQTT 实时推送的人脸变更(立即落库,不等下一轮轮询)
|
||||
*
|
||||
* 处理语义与增量接口一致,另加乱序守卫:
|
||||
* 按 userFaceId 查本地记录,消息时间戳不大于本地时间戳的条目直接丢弃,
|
||||
* 防止 QoS1 重复投递或乱序到达时旧数据覆盖新数据。
|
||||
*
|
||||
* 注意:本方法不推进增量水位。水位只由 HTTP 增量接口的响应推进,
|
||||
* 后台推送失败只记日志不重发,若 MQ 消息把水位推到漏发变更之后,
|
||||
* 轮询/补拉将永远拉不到那条变更。
|
||||
*/
|
||||
private fun applyRealtimeUpdates(items: List<UserFaceModelV2>) {
|
||||
if (items.isEmpty()) return
|
||||
|
||||
// 批内去重:同一 userFaceId 仅保留时间戳最大的一条(含其删除标志)
|
||||
val batch = items.groupBy { it.userFaceId }
|
||||
.flatMap { (key, list) ->
|
||||
if (key.isNullOrEmpty()) list
|
||||
else listOfNotNull(list.maxByOrNull { it.faceUpdateTimestamp ?: 0L })
|
||||
}
|
||||
|
||||
// 乱序守卫:本地已有同 userFaceId 且时间戳不旧的记录则跳过该条
|
||||
val fresh = batch.filter { msg ->
|
||||
val key = msg.userFaceId
|
||||
if (key.isNullOrEmpty()) return@filter true
|
||||
val local = faceApi.queryByUserFaceId(key)
|
||||
val msgTs = msg.faceUpdateTimestamp ?: 0L
|
||||
when {
|
||||
local == null -> true
|
||||
msgTs <= 0L -> true
|
||||
msgTs > local.faceUpdateTimestamp -> true
|
||||
else -> {
|
||||
Timber.d("$TAG 实时消息乱序/重复,丢弃 userFaceId=$key ts=$msgTs")
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
if (fresh.isEmpty()) return
|
||||
|
||||
var changed = false
|
||||
for (msg in fresh) {
|
||||
if (msg.faceDeleted == true) {
|
||||
val userFaceId = msg.userFaceId
|
||||
if (!userFaceId.isNullOrEmpty() && faceApi.queryByUserFaceId(userFaceId) != null) {
|
||||
faceApi.deleteByUserFaceId(userFaceId)
|
||||
changed = true
|
||||
}
|
||||
} else {
|
||||
// 优先按 userFaceId 精确判重,避免特征字段不一致导致重复入库
|
||||
val userFaceId = msg.userFaceId
|
||||
if (!userFaceId.isNullOrEmpty() && faceApi.queryByUserFaceId(userFaceId) != null) {
|
||||
continue
|
||||
}
|
||||
val entity = buildEntity(msg) ?: continue
|
||||
faceApi.insert(entity)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
Timber.i("$TAG MQTT 实时更新完成 ${items.size} 条(实际变更 $changed 条),通知界面刷新引擎")
|
||||
EventBus.getDefault().post(FaceChangedEvent())
|
||||
}
|
||||
}
|
||||
|
||||
/** 将 MQTT 消息转换为 [FaceEntity],特征 Base64 为空或解码失败返回 null */
|
||||
private fun buildEntity(model: UserFaceModelV2): FaceEntity? {
|
||||
val featureBase64 = model.resolveFeatureStr()
|
||||
if (featureBase64.isNullOrBlank()) {
|
||||
Timber.w("$TAG 人脸特征为空,跳过入库: userFaceId=${model.userFaceId} userId=${model.userId}")
|
||||
return null
|
||||
}
|
||||
return try {
|
||||
model.toFaceEntity()
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "$TAG 人脸特征 Base64 解码失败: userFaceId=${model.userFaceId}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.sw.platecabinet.mqtt
|
||||
|
||||
/**
|
||||
* MQTT 连接配置
|
||||
*
|
||||
* @param host Broker 主机地址
|
||||
* @param port Broker 端口,默认 1883
|
||||
* @param scheme 连接协议:tcp(裸 MQTT)、ws(MQTT over WebSocket)、wss(WebSocket + TLS)。
|
||||
* EMQX 惯例 1883=tcp / 8083=ws / 8084=wss,端口与协议不匹配时 Broker 会在
|
||||
* CONNACK 前直接断开(32109/EOFException)
|
||||
* @param clientId 客户端唯一标识;同一 Broker 下多台设备禁止重复,否则旧连接会被踢下线
|
||||
* @param userName 鉴权用户名,null 表示匿名连接
|
||||
* @param password 鉴权密码,null 表示无密码
|
||||
* @param cleanSession false 时保留会话(离线期间 QoS1 消息由 Broker 暂存,重连后补收)
|
||||
* @param keepAliveInterval 心跳间隔(秒)
|
||||
* @param connectionTimeout 连接超时(秒)
|
||||
*/
|
||||
data class MqttConfig(
|
||||
val host: String,
|
||||
val port: Int = 1883,
|
||||
val scheme: String = "tcp",
|
||||
val clientId: String? = null,
|
||||
val userName: String? = null,
|
||||
val password: String? = null,
|
||||
val cleanSession: Boolean = true,
|
||||
val keepAliveInterval: Int = 20,
|
||||
val connectionTimeout: Int = 30
|
||||
) {
|
||||
val brokerUrl: String get() = "$scheme://$host:$port"
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package com.sw.platecabinet.mqtt
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken
|
||||
import org.eclipse.paho.client.mqttv3.MqttCallbackExtended
|
||||
import org.eclipse.paho.client.mqttv3.MqttClient
|
||||
import org.eclipse.paho.client.mqttv3.MqttConnectOptions
|
||||
import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence
|
||||
import timber.log.Timber
|
||||
|
||||
/** MQTT 连接状态 */
|
||||
enum class MqttState { Disconnected, Connecting, Connected }
|
||||
|
||||
/**
|
||||
* MQTT 连接管理器(进程级单例,替代 Hilt 注入)
|
||||
*
|
||||
* 负责 Broker 连接、自动重连、订阅管理与消息分发:
|
||||
* - 启用 Paho 自动重连,重连成功后自动恢复历史订阅([MqttCallbackExtended.connectComplete])
|
||||
* - 业务消息通过 [messages] SharedFlow 分发,业务层按 topic 过滤消费
|
||||
* - cleanSession=false 时 Broker 会暂存离线期间的 QoS1 消息,重连后补收
|
||||
*/
|
||||
object MqttManager {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var client: MqttClient? = null
|
||||
private var config: MqttConfig? = null
|
||||
|
||||
/** 已订阅的 topic 及 QoS,自动重连成功后按此恢复订阅 */
|
||||
private val subscriptions = linkedMapOf<String, Int>()
|
||||
|
||||
private val _state = MutableStateFlow(MqttState.Disconnected)
|
||||
val state: StateFlow<MqttState> = _state.asStateFlow()
|
||||
|
||||
private val _messages = MutableSharedFlow<MqttMessage>(extraBufferCapacity = 20)
|
||||
val messages: SharedFlow<MqttMessage> = _messages.asSharedFlow()
|
||||
|
||||
/** 首连失败后的退避重试协程,连接成功或主动断开时取消 */
|
||||
private var retryJob: Job? = null
|
||||
|
||||
fun configure(cfg: MqttConfig) {
|
||||
config = cfg
|
||||
Timber.d("MQTT 配置完成: ${cfg.brokerUrl}")
|
||||
}
|
||||
|
||||
fun connect() {
|
||||
val cfg = config ?: run {
|
||||
Timber.e("MQTT 未配置,请先调用 configure()")
|
||||
return
|
||||
}
|
||||
// 已连接或正在连接时重复调用直接忽略,避免重建客户端导致重复连接
|
||||
if (_state.value != MqttState.Disconnected) {
|
||||
Timber.d("MQTT 当前状态=${_state.value},忽略重复 connect()")
|
||||
return
|
||||
}
|
||||
retryJob?.cancel()
|
||||
retryJob = scope.launch { doConnect(cfg) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立连接,失败则指数退避重试
|
||||
*
|
||||
* Paho 的 automaticReconnect 仅在首连成功后生效;设备开机时 Broker 可能
|
||||
* 尚未就绪导致首连失败,故此处自建退避循环兜底(1s 起、翻倍、60s 封顶)。
|
||||
*/
|
||||
private suspend fun doConnect(cfg: MqttConfig) {
|
||||
val id = cfg.clientId ?: "platecabinet_${System.currentTimeMillis()}"
|
||||
var backoffMs = 1_000L
|
||||
while (true) {
|
||||
try {
|
||||
_state.value = MqttState.Connecting
|
||||
client = MqttClient(cfg.brokerUrl, id, MemoryPersistence()).apply {
|
||||
setCallback(createCallback())
|
||||
val options = MqttConnectOptions().apply {
|
||||
isCleanSession = cfg.cleanSession
|
||||
keepAliveInterval = cfg.keepAliveInterval
|
||||
connectionTimeout = cfg.connectionTimeout
|
||||
isAutomaticReconnect = true
|
||||
maxReconnectDelay = 60_000
|
||||
// 鉴权信息(用户名非空时才注入,避免空串覆盖匿名连接)
|
||||
cfg.userName?.let { userName = it }
|
||||
cfg.password?.let { password = it.toCharArray() }
|
||||
}
|
||||
connect(options)
|
||||
}
|
||||
_state.value = MqttState.Connected
|
||||
Timber.i("MQTT 连接成功: ${cfg.brokerUrl}, clientId=$id, cleanSession=${cfg.cleanSession}")
|
||||
return
|
||||
} catch (e: Exception) {
|
||||
// 连接失败时 MqttClient 可能残留半初始化状态,先关闭清理
|
||||
try { client?.close() } catch (_: Exception) {}
|
||||
client = null
|
||||
_state.value = MqttState.Disconnected
|
||||
_messages.emit(MqttMessage.Error("连接失败: ${e.message}", e))
|
||||
Timber.e(e, "MQTT 连接失败,${backoffMs / 1000} 秒后重试")
|
||||
delay(backoffMs)
|
||||
backoffMs = (backoffMs * 2).coerceAtMost(60_000L)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
// 取消首连重试循环,避免主动断开后仍反复重连
|
||||
retryJob?.cancel()
|
||||
retryJob = null
|
||||
scope.launch {
|
||||
try { client?.disconnect() } catch (_: Exception) {}
|
||||
client?.close()
|
||||
client = null
|
||||
_state.value = MqttState.Disconnected
|
||||
_messages.emit(MqttMessage.Disconnected())
|
||||
Timber.d("MQTT 已断开")
|
||||
}
|
||||
}
|
||||
|
||||
/** 订阅主题(记录 QoS,自动重连成功后由 [createCallback] 恢复) */
|
||||
fun subscribe(topic: String, qos: Int = 0) {
|
||||
synchronized(subscriptions) { subscriptions[topic] = qos }
|
||||
scope.launch {
|
||||
// 未连接时仅记录订阅关系,待连接成功后由 connectComplete 恢复,
|
||||
// 不发 Subscribed 事件以免误报"订阅成功"
|
||||
val current = client ?: run {
|
||||
Timber.d("MQTT 未连接,订阅 $topic 已记录,连接成功后自动恢复")
|
||||
return@launch
|
||||
}
|
||||
try {
|
||||
// subscribe 返回 Broker 实际授予的 QoS(0x80 表示被拒绝),用于联调排查
|
||||
current.subscribe(topic, qos)
|
||||
_messages.emit(MqttMessage.Subscribed(topic))
|
||||
Timber.i("MQTT 订阅成功: $topic qos=$qos")
|
||||
} catch (e: Exception) {
|
||||
_messages.emit(MqttMessage.Error("订阅失败: ${e.message}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun publish(topic: String, payload: ByteArray, qos: Int = 0, retain: Boolean = false) {
|
||||
scope.launch {
|
||||
try {
|
||||
client?.publish(topic, payload, qos, retain)
|
||||
_messages.emit(MqttMessage.Published(topic))
|
||||
} catch (e: Exception) {
|
||||
_messages.emit(MqttMessage.Error("发布失败: ${e.message}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
disconnect()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
/** 使用 Extended 回调:connectComplete 在首次连接与自动重连成功时都会回调,用于恢复订阅 */
|
||||
private fun createCallback() = object : MqttCallbackExtended {
|
||||
override fun connectComplete(reconnect: Boolean, serverURI: String?) {
|
||||
scope.launch {
|
||||
_state.value = MqttState.Connected
|
||||
Timber.i("MQTT connectComplete: reconnect=$reconnect serverURI=$serverURI")
|
||||
// 重连后恢复历史订阅(cleanSession=false 时 Broker 已保留订阅,重复订阅幂等)
|
||||
val restore = synchronized(subscriptions) { subscriptions.toMap() }
|
||||
if (restore.isNotEmpty()) {
|
||||
try {
|
||||
client?.subscribe(restore.keys.toTypedArray(), restore.values.toIntArray())
|
||||
Timber.i("MQTT ${if (reconnect) "重连" else "连接"}完成,已恢复订阅 ${restore.size} 个: ${restore.keys}")
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "MQTT 恢复订阅失败")
|
||||
}
|
||||
} else {
|
||||
Timber.w("MQTT 连接完成但无历史订阅(尚未调用过 subscribe)")
|
||||
}
|
||||
_messages.emit(MqttMessage.Connected)
|
||||
}
|
||||
}
|
||||
|
||||
override fun connectionLost(cause: Throwable?) {
|
||||
scope.launch {
|
||||
_state.value = MqttState.Disconnected
|
||||
_messages.emit(MqttMessage.Disconnected(cause))
|
||||
// cause 可能为 null(如 Broker 主动踢 clientId 重复时仅表现为静默断开),
|
||||
// 打印消息体帮助定位掉线原因
|
||||
Timber.w(cause, "MQTT 连接丢失: ${cause?.message ?: "无异常信息(可能是 Broker 踢线/网络中断)"}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun messageArrived(topic: String?, message: org.eclipse.paho.client.mqttv3.MqttMessage?) {
|
||||
if (topic != null && message != null) {
|
||||
Timber.i(
|
||||
"MQTT messageArrived: topic=$topic size=${message.payload.size} " +
|
||||
"qos=${message.qos} dup=${message.isDuplicate} retained=${message.isRetained}"
|
||||
)
|
||||
scope.launch {
|
||||
_messages.emit(MqttMessage.Received(topic, message.payload))
|
||||
}
|
||||
} else {
|
||||
Timber.w("MQTT messageArrived 收到空消息: topic=$topic message=$message")
|
||||
}
|
||||
}
|
||||
|
||||
override fun deliveryComplete(token: IMqttDeliveryToken?) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.sw.platecabinet.mqtt
|
||||
|
||||
/** MQTT 事件消息密封类 */
|
||||
sealed class MqttMessage {
|
||||
data object Connected : MqttMessage()
|
||||
data class Disconnected(val cause: Throwable? = null) : MqttMessage()
|
||||
data class Received(val topic: String, val payload: ByteArray) : MqttMessage()
|
||||
data class Subscribed(val topic: String) : MqttMessage()
|
||||
data class Published(val topic: String) : MqttMessage()
|
||||
data class Error(val message: String, val throwable: Throwable? = null) : MqttMessage()
|
||||
}
|
||||
@@ -137,7 +137,7 @@ class NetViewModelV2 : ViewModel() {
|
||||
}
|
||||
|
||||
ErrorInfo.MERR_ASF_ALREADY_ACTIVATED -> {
|
||||
// ToastUtils.showToast("引擎已激活,无需再次激活")
|
||||
ToastUtils.showToast("引擎已激活,无需再次激活")
|
||||
}
|
||||
|
||||
else -> {
|
||||
|
||||
@@ -12,52 +12,40 @@
|
||||
android:id="@+id/includeHeader"
|
||||
layout="@layout/item_title_time" />
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="2"/>
|
||||
|
||||
<!-- 内容区:占满头部以下剩余空间并居中显示 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="142dp"
|
||||
android:src="@drawable/ic_init_text"
|
||||
android:adjustViewBounds="true"/>
|
||||
android:adjustViewBounds="true"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_init_text" />
|
||||
|
||||
<ImageView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="345dp"
|
||||
android:layout_marginTop="90dp"
|
||||
android:src="@drawable/ic_init_img"
|
||||
android:adjustViewBounds="true" />
|
||||
android:adjustViewBounds="true"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_init_img" />
|
||||
|
||||
<!-- <ImageView-->
|
||||
<!-- android:layout_width="wrap_content"-->
|
||||
<!-- android:layout_height="wrap_content"-->
|
||||
<!-- android:layout_marginTop="20dp"-->
|
||||
<!-- android:src="@drawable/ic_init_press" />-->
|
||||
|
||||
<TextView android:id="@+id/takeButton"
|
||||
<TextView
|
||||
android:id="@+id/takeButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="60dp"
|
||||
android:text="点击取盘"
|
||||
android:textColor="#FFCC99"
|
||||
android:textSize="24sp"
|
||||
android:paddingHorizontal="45dp"
|
||||
android:gravity="center"
|
||||
android:background="@drawable/btn_outline"
|
||||
tools:ignore="HardcodedText" />
|
||||
android:gravity="center"
|
||||
android:paddingStart="45dp"
|
||||
android:paddingEnd="45dp"
|
||||
android:text="@string/init_take_plate"
|
||||
android:textColor="@color/init_button_text"
|
||||
android:textSize="24sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"/>
|
||||
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="24dp"
|
||||
android:background="@drawable/bg_dialog">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:text="选择业务环境"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<RadioGroup
|
||||
android:id="@+id/rgEnvironments"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:orientation="vertical" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvConfirm"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="20dp"
|
||||
android:background="@drawable/bg_init_button"
|
||||
android:gravity="center"
|
||||
android:padding="10dp"
|
||||
android:text="确定"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="20sp" />
|
||||
</LinearLayout>
|
||||
@@ -15,4 +15,5 @@
|
||||
<color name="tip_title_success">#ff02f1be</color>
|
||||
<color name="tip_title_fail">#FFCC99</color>
|
||||
<color name="tip_sub_title">#FFCC99</color>
|
||||
<color name="init_button_text">#FFCC99</color>
|
||||
</resources>
|
||||
@@ -1,5 +1,6 @@
|
||||
<resources>
|
||||
<string name="app_name">餐盘柜会员版</string>
|
||||
<string name="init_take_plate">点击取盘</string>
|
||||
<!-- TODO: Remove or change this placeholder text -->
|
||||
<string name="hello_blank_fragment">Hello blank fragment</string>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user