fix(mqtt/ota): 修同一遠端指令被投遞多次致 OTA 下載互相取消而失敗

- MqttManager: 收訊改每個 client 註冊一次的全域 publishes 回呼;subscribeToCommandTopic 只送 SUBSCRIBE 不再掛 callback(每次重連累積 callback=元凶);加 command_id 去重(30s)
- OtaManager: startUpdate 加同 cmdId 防重入 guard,避免並行 cancelExisting 互相取消下載

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
terrylee 2026-07-16 14:07:05 +08:00
parent bfaad4ea3b
commit d224354c23
2 changed files with 57 additions and 12 deletions

View File

@ -27,6 +27,10 @@ public class MqttManager {
private static final String TAG = "MqttManager";
private static MqttManager instance;
private Mqtt3AsyncClient client;
// 指令去重保險同一 command_id 短時間內只處理一次 MQTT 重複投遞QoS1 重送等導致同指令被執行多次
private final java.util.Map<String, Long> recentCmdIds = new java.util.concurrent.ConcurrentHashMap<>();
private static final long CMD_DEDUP_WINDOW_MS = 30000L;
private final Gson gson = new Gson();
private com.unibuy.smartdevice.exception.Logs logs;
private String serialNo;
@ -129,6 +133,38 @@ public class MqttManager {
})
.buildAsync();
// 收訊回呼每個 client 只註冊一次的全域 publishes取代每次重連 subscribe 都掛一個 callback 的舊寫法
// 舊寫法會在一整天斷線重連中累積數十個 callback導致同一則指令被處理數十次OTA 因此並行互相取消而失敗
final String globalCommandTopic = "machine/" + machineId + "/command";
client.publishes(com.hivemq.client.mqtt.MqttGlobalPublishFilter.ALL, publish -> {
try {
if (!globalCommandTopic.equals(publish.getTopic().toString())) {
return;
}
String payloadString = new String(publish.getPayloadAsBytes(), StandardCharsets.UTF_8);
logs.info("Received command payload: " + payloadString);
CommandPayload command = gson.fromJson(payloadString, CommandPayload.class);
if (commandListener != null && command != null) {
// 去重同一 command_id 短時間內重複投遞只處理一次
String cid = command.getCommand_id();
if (cid != null && !cid.isEmpty()) {
long nowMs = System.currentTimeMillis();
Long lastMs = recentCmdIds.put(cid, nowMs);
if (lastMs != null && (nowMs - lastMs) < CMD_DEDUP_WINDOW_MS) {
logs.info("Duplicate MQTT command ignored (ID: " + cid + ")");
return;
}
if (recentCmdIds.size() > 200) {
recentCmdIds.entrySet().removeIf(en -> nowMs - en.getValue() > 60000L);
}
}
commandListener.onCommandReceived(command);
}
} catch (Exception e) {
logs.info("Error parsing command payload: " + e.getMessage());
}
});
client.connectWith()
.cleanSession(true)
.keepAlive(30) // Broker 判斷斷線 = 30 × 1.5 = 45 加快 LWT 觸發
@ -252,21 +288,11 @@ public class MqttManager {
return;
String commandTopic = "machine/" + serialNo + "/command";
// 只送 SUBSCRIBE 建立訂閱cleanSession 每次重連都要重送不再於此掛 callback
// 收訊統一走 connect() 內註冊一次的全域 publishes 回呼避免重連累積多個 callback
client.subscribeWith()
.topicFilter(commandTopic)
.qos(MqttQos.AT_LEAST_ONCE)
.callback(publish -> {
String payloadString = new String(publish.getPayloadAsBytes(), StandardCharsets.UTF_8);
logs.info("Received command payload: " + payloadString);
try {
CommandPayload command = gson.fromJson(payloadString, CommandPayload.class);
if (commandListener != null && command != null) {
commandListener.onCommandReceived(command);
}
} catch (Exception e) {
logs.info("Error parsing command payload: " + e.getMessage());
}
})
.send()
.whenComplete((subAck, throwable) -> {
if (throwable != null) {

View File

@ -65,6 +65,13 @@ public class OtaManager {
private static volatile boolean awaitingIdleInstall = false;
private static BroadcastReceiver completionReceiver;
// 防重入MQTT 可能重複投遞同一則 update_app機台若有多條堆疊連線會收到 N
// 同一 cmdId 已在進行中就忽略重複避免 N 個並行 startUpdate cancelExisting() 互相取消下載
// 不在 clearState 清除cancelExisting 會於 startUpdate 開頭呼叫 clearState改由新 cmdId 覆蓋 + 15 分鐘保險過期
private static volatile String activeOtaCmdId = null;
private static volatile long activeOtaStartMs = 0L;
private static final long OTA_REENTRY_WINDOW_MS = 15 * 60 * 1000L;
/** 相容舊呼叫端:未帶版本資訊。 */
public static void startUpdate(Context context, String apkUrl, String cmdId) {
startUpdate(context, apkUrl, cmdId, null);
@ -81,6 +88,18 @@ public class OtaManager {
public static void startUpdate(Context context, String apkUrl, String cmdId, String expectedVersion) {
Context appContext = context.getApplicationContext();
MqttManager mqttManager = MqttManager.getInstance();
// 防重入同一 cmdId 的重複投遞直接忽略避免並行 startUpdate 互相 cancelExisting() 取消下載
synchronized (OtaManager.class) {
long now = System.currentTimeMillis();
if (cmdId != null && cmdId.equals(activeOtaCmdId) && (now - activeOtaStartMs) < OTA_REENTRY_WINDOW_MS) {
logs.info("OTA duplicate command ignored (cmdId=" + cmdId + "); already in progress.");
return;
}
activeOtaCmdId = cmdId;
activeOtaStartMs = now;
}
try {
logs.info("Starting OTA update process. URL: " + apkUrl + " (cmdId: " + cmdId
+ ", version: " + expectedVersion + ")");