[FIX] 修復遠端出貨流程與 MQTT 回傳機制

1. 修復 SchedulerAbstract.java 的 shutdown() 方法在多執行緒環境下因 scheduler 被設為 null 而導致的 NullPointerException。
2. 強化 ProductDispenseManager.java 的輪詢機制,在重新輪詢的 lambda 中加入 try-catch 安全網,確保即使排程異常也能觸發 onFailure 回調發送 ACK。
3. 優化 MqttService.java 對 dispense 指令的處理,增加對成功/失敗回調的日誌記錄與 Command ACK 發送。
4. 更新 TransactionFinalizeBuilder.java 與相關 Payload 結構,確保 code_id 以字串形式正確上報,並加入 remote_command_id 欄位以利後台對帳。
5. 修正 DevXinYuanController.java 對 VMC_04_BUY_STATUS 的處理,確保不論成功或處理中狀態皆能透過 MQTT 診斷 Topic 上報。
6. 修正 DevController.java 中 getReadBuffer 邏輯,避免在重新啟動排程時出現潛在競爭。
This commit is contained in:
sky121113 2026-05-12 13:24:33 +08:00
parent f5619e754e
commit 98cb59c431
10 changed files with 588 additions and 343 deletions

View File

@ -27,18 +27,23 @@ cp app/build/outputs/apk/S_12_XY_U_Standard_/debug/app-S_12_XY_U_Standard_-10_08
echo "✅ APK 已經複製到專案根目錄的 outputs 資料夾中"
```
## 步驟三:透過 Windows ADB 安裝到手機 / 機台
利用 WSL 串接 Windows 端的 ADB將熱騰騰的 APK 覆蓋安裝到目前連接的設備上
## 步驟三:安裝並啟動 App (如果有連接設備)
利用 WSL 串接 Windows 端的 ADB偵測到設備後才執行安裝與啟動
```bash
// turbo
/mnt/c/Users/User/AppData/Local/Android/Sdk/platform-tools/adb.exe install -r -d -t /home/mama/projects/TaiwanStar_general_size_s_Chengwai/outputs/TaiwanStar_debug.apk
```
ADB_PATH="/mnt/c/Users/User/AppData/Local/Android/Sdk/platform-tools/adb.exe"
APK_PATH="/home/mama/projects/TaiwanStar_general_size_s_Chengwai/outputs/TaiwanStar_debug.apk"
## 步驟四:自動啟動 App
安裝完成後,直接在手機上喚醒並啟動應用程式 (`HomeActivity`)。
# 檢查是否有設備連接
DEVICE_COUNT=$($ADB_PATH devices | grep -v "List of devices" | grep "device" | wc -l)
```bash
// turbo
/mnt/c/Users/User/AppData/Local/Android/Sdk/platform-tools/adb.exe shell am start -n com.unibuy.smartdevice/com.unibuy.smartdevice.HomeActivity
if [ "$DEVICE_COUNT" -gt 0 ]; then
echo "📱 偵測到 $DEVICE_COUNT 個設備,開始安裝..."
$ADB_PATH install -r -d -t $APK_PATH
echo "🚀 安裝完成,正在啟動 App..."
$ADB_PATH shell am start -n com.unibuy.smartdevice/com.unibuy.smartdevice.HomeActivity
else
echo "⚠️ 未偵測到連接的手機或設備,跳過安裝與啟動步驟。"
fi
```

View File

@ -157,15 +157,17 @@ public abstract class SchedulerAbstract implements Runnable {
}
public void shutdown() {
if (scheduler != null && !scheduler.isShutdown()) {
scheduler.shutdown();
// 使用本地變數快照避免 volatile 欄位在多執行緒間被 initScheduler() 設為 null 導致 NPE
ScheduledExecutorService s = scheduler;
if (s != null && !s.isShutdown()) {
s.shutdown();
try {
if (!scheduler.awaitTermination(1, TimeUnit.SECONDS)) {
scheduler.shutdownNow();
scheduler = null;
if (!s.awaitTermination(1, TimeUnit.SECONDS)) {
s.shutdownNow();
}
} catch (InterruptedException e) {
scheduler.shutdownNow();
s.shutdownNow();
} finally {
scheduler = null;
}
}

View File

@ -35,6 +35,7 @@ public abstract class DevController extends HandlerMainScheduler {
public void getReadBuffer(ReadBufferOnScheduler readBufferOnScheduler, long delayMillis) {
if (this.isRun()) {
readBufferOnScheduler.shutdown();
readBufferOnScheduler.setDevController(this);
readBufferOnScheduler.start(delayMillis, TimeUnit.MILLISECONDS);
}
@ -129,8 +130,9 @@ public abstract class DevController extends HandlerMainScheduler {
}
public List<byte[]> getReadBufferByMax() {
int size = readBuffers.size();
return readBuffers;
List<byte[]> buffers = new ArrayList<>(readBuffers);
readBuffers.clear();
return buffers;
}
public byte[] getReadBuffer() {

View File

@ -19,6 +19,7 @@ import com.unibuy.smartdevice.exception.LogsParseException;
import com.unibuy.smartdevice.exception.LogsSettingEmptyException;
import com.unibuy.smartdevice.exception.LogsUnsupportedOperationException;
import com.unibuy.smartdevice.external.HttpAPI;
import com.unibuy.smartdevice.external.mqtt.MqttManager;
import com.unibuy.smartdevice.tools.HandlerMain;
import com.unibuy.smartdevice.ui.FontendActivity;
@ -27,7 +28,6 @@ import java.util.Map;
public class DevXinYuanController extends DevController {
private Logs logs;
private HttpAPI httpAPI;
private Rs232Dev rs232Dev;
private DevXinYuan devXinYuan;
private static int temperature = 0;
@ -95,23 +95,13 @@ public class DevXinYuanController extends DevController {
// optional delay so it doesn't collide with real events
Thread.sleep(5000);
if (httpAPI == null) {
httpAPI = new HttpAPI(getHandlerMain());
}
MqttManager.getInstance().publishError(slot, "0402");
logs.info("Simulated VMC_04_BUY_STATUS (0402) published to MQTT.");
// runOnUiThread(() -> Toast.makeText(MyApp.getInstance(), "[Fake] 準備送出: slot=" + slot , Toast.LENGTH_SHORT).show());
httpAPI.errorPost(slot, "0402");
// runOnUiThread(() -> Toast.makeText(MyApp.getInstance(), "[Fake] 已送出: slot=" + slot , Toast.LENGTH_SHORT).show());
} catch (InterruptedException ie) {
logs.warning(ie);
} catch (LogsSettingEmptyException | LogsParseException e) {
logs.warning(e);
runOnUiThread(() -> Toast.makeText(MyApp.getInstance(), "[Fake] 發送失敗: " + e.getMessage(), Toast.LENGTH_LONG).show());
} catch (Exception e) {
logs.warning(e);
runOnUiThread(() -> Toast.makeText(MyApp.getInstance(), "[Fake] 例外: " + e.getClass().getSimpleName(), Toast.LENGTH_LONG).show());
}
}).start();
}
@ -191,48 +181,29 @@ public class DevXinYuanController extends DevController {
rs232Dev.send(devXinYuan.sendACK());
break;
case VMC_04_BUY_STATUS:
new Thread(() -> {
try {
if (httpAPI == null) httpAPI = new HttpAPI(getHandlerMain());
int slot = ((vmcData[2] & 0xFF) << 8) | (vmcData[3] & 0xFF);
// String statusMessage = PortTools.showHex(new byte[]{vmcCmd[0],vmcData[1]}).replace(" ", "");
byte statusByte = vmcData[1];
String statusMessage = String.format("%02X%02X", vmcCmd[0] & 0xFF, statusByte & 0xFF);
String statusMessage = String.format("%02X%02X", vmcCmd[0] & 0xFF, vmcData[1] & 0xFF);
// runOnUiThread(() ->
// Toast.makeText(MyApp.getInstance(), "傳送訊息"+statusMessage, Toast.LENGTH_LONG).show()
// );
//最後一筆0402得到貨道是0可能覆蓋 //所以抓取前面三筆出貨前的資料
if(slot>0){
sllloot= slot;
}
//放這邊因為最後一筆 出貨成功跟微波爐請取餐 可能排成造成的無法傳送API後台
//也許原因是因為速度過快覆蓋 所以而外拉出來一個方法來處理 0402
if(statusMessage.equals("0402")){
simulateVmc04BuyStatus(sllloot);
if (slot > 0) {
sllloot = slot;
}
httpAPI.errorPost(slot, statusMessage);
// 依照要求直接上報所有狀態 (包含 0401 處理中, 0402/0424 成功)
MqttManager.getInstance().publishError(slot, statusMessage);
logs.info("VMC_04_BUY_STATUS MQTT status published: " + statusMessage + " (slot: " + slot + ")");
logs.info("VMC_04_BUY_STATUS errorPost sent.");
} catch (LogsSettingEmptyException | LogsParseException e) {
logs.warning(e);
runOnUiThread(() ->
Toast.makeText(MyApp.getInstance(), "錯誤: " + e.getMessage(), Toast.LENGTH_SHORT).show()
);
} catch (Exception e) {
logs.warning(e); // 不再丟出 RuntimeException
logs.warning(e);
}
}).start();
/// ////////////////
publishReadBuffer(PortTools.concatByteArrays(vmcCmd[0], vmcData[1]));
rs232Dev.send(devXinYuan.sendACK());
break;
case VMC_11_GIVE_PRODUCT_INFO:
publishReadBuffer(PortTools.concatByteArrays(vmcCmd[0], vmcData[1]));
rs232Dev.send(devXinYuan.sendACK());
break;

View File

@ -28,11 +28,14 @@ public class MqttManager {
private static MqttManager instance;
private Mqtt3AsyncClient client;
private final Gson gson = new Gson();
private com.unibuy.smartdevice.exception.Logs logs;
private String serialNo;
private MqttCommandListener commandListener;
private ScheduledExecutorService heartbeatExecutor;
private MqttManager() {}
private MqttManager() {
logs = new com.unibuy.smartdevice.exception.Logs(getClass());
}
public static synchronized MqttManager getInstance() {
if (instance == null) {
@ -47,7 +50,7 @@ public class MqttManager {
public void connect(String machineId, String apiToken) {
if (machineId == null || machineId.isEmpty() || apiToken == null || apiToken.isEmpty()) {
Log.e(TAG, "Cannot connect to MQTT: machineId or apiToken is empty");
logs.info("Cannot connect to MQTT: machineId or apiToken is empty");
return;
}
@ -55,7 +58,7 @@ public class MqttManager {
if (client != null && serialNo != null && serialNo.equals(machineId)) {
MqttClientState state = client.getState();
if (state != MqttClientState.DISCONNECTED) {
Log.i(TAG, "MQTT is already " + state + " for: " + machineId);
logs.info("MQTT is already " + state + " for: " + machineId);
return;
}
}
@ -82,15 +85,15 @@ public class MqttManager {
.serverPort(443)
.useSslWithDefaultConfig()
.webSocketConfig()
.serverPath("/mqtt")
.applyWebSocketConfig()
.serverPath("/mqtt")
.applyWebSocketConfig()
.simpleAuth()
.username(machineId)
.password(apiToken.getBytes(StandardCharsets.UTF_8))
.applySimpleAuth()
.username(machineId)
.password(apiToken.getBytes(StandardCharsets.UTF_8))
.applySimpleAuth()
.automaticReconnectWithDefaultConfig()
.addConnectedListener(context -> {
Log.i(TAG, "MQTT Connection established/recovered");
logs.info("MQTT Connection established/recovered");
publishStatus("online");
subscribeToCommandTopic();
startHeartbeatScheduler();
@ -99,19 +102,19 @@ public class MqttManager {
client.connectWith()
.cleanSession(true)
.keepAlive(30) // Broker 判斷斷線 = 30 × 1.5 = 45 加快 LWT 觸發
.keepAlive(30) // Broker 判斷斷線 = 30 × 1.5 = 45 加快 LWT 觸發
.willPublish()
.topic(willTopic)
.payload(willPayload.getBytes(StandardCharsets.UTF_8))
.qos(MqttQos.AT_LEAST_ONCE)
.retain(true)
.applyWillPublish()
.topic(willTopic)
.payload(willPayload.getBytes(StandardCharsets.UTF_8))
.qos(MqttQos.AT_LEAST_ONCE)
.retain(true)
.applyWillPublish()
.send()
.whenComplete((connAck, throwable) -> {
if (throwable != null) {
Log.e(TAG, "MQTT Initial connection failed: " + throwable.getMessage(), throwable);
logs.info("MQTT Initial connection failed: " + throwable.getMessage());
} else {
Log.i(TAG, "MQTT Initial connection successful");
logs.info("MQTT Initial connection successful");
}
});
}
@ -125,7 +128,8 @@ public class MqttManager {
try {
String ver = "0";
try {
PackageInfo packageInfo = MyApp.getInstance().getPackageManager().getPackageInfo(MyApp.getInstance().getPackageName(), 0);
PackageInfo packageInfo = MyApp.getInstance().getPackageManager()
.getPackageInfo(MyApp.getInstance().getPackageName(), 0);
ver = packageInfo.versionName;
} catch (PackageManager.NameNotFoundException e) {
// ignore
@ -133,7 +137,7 @@ public class MqttManager {
int temperature = DevXinYuanController.getTemperature();
publishHeartbeat(ver, temperature);
} catch (Exception e) {
Log.e(TAG, "Error in heartbeat scheduler", e);
logs.info("Error in heartbeat scheduler: " + e.getMessage());
}
}, 0, 60, TimeUnit.SECONDS);
}
@ -157,9 +161,9 @@ public class MqttManager {
* Broker 不會觸發 LWT 遺囑訊息因此可避免管理後台誤報離線
*
* 呼叫流程
* 1. publishStatus("restarting") 告知後台為計畫性重啟
* 2. 等發布完成後呼叫 client.disconnect() 正常斷線LWT 不觸發
* 3. 執行 onComplete callback 呼叫方在此執行 killProcess()
* 1. publishStatus("restarting") 告知後台為計畫性重啟
* 2. 等發布完成後呼叫 client.disconnect() 正常斷線LWT 不觸發
* 3. 執行 onComplete callback 呼叫方在此執行 killProcess()
*
* @param onComplete 斷線完成後的回呼用來執行 killProcess/System.exit 等後續動作
*/
@ -171,15 +175,16 @@ public class MqttManager {
if (client == null || !client.getState().isConnected()) {
// 如果本來就沒有連線直接執行後續動作
Log.i(TAG, "gracefulDisconnect: MQTT not connected, skip directly.");
if (onComplete != null) onComplete.run();
logs.info("gracefulDisconnect: MQTT not connected, skip directly.");
if (onComplete != null)
onComplete.run();
return;
}
// 發布 restarting 狀態retain=true後台可感知這是計畫性重啟
String payload = "{\"status\":\"restarting\"}";
String topic = "machine/" + serialNo + "/status";
Log.i(TAG, "gracefulDisconnect: publishing restarting status...");
logs.info("gracefulDisconnect: publishing restarting status...");
client.publishWith()
.topic(topic)
@ -189,56 +194,59 @@ public class MqttManager {
.send()
.whenComplete((publishResult, pubThrowable) -> {
if (pubThrowable != null) {
Log.w(TAG, "gracefulDisconnect: failed to publish restarting status, disconnecting anyway.");
logs.info("gracefulDisconnect: failed to publish restarting status, disconnecting anyway.");
} else {
Log.i(TAG, "gracefulDisconnect: restarting status published.");
logs.info("gracefulDisconnect: restarting status published.");
}
// 無論發布成否都執行正常 DISCONNECT避免 LWT 觸發
client.disconnect()
.whenComplete((v, disconnThrowable) -> {
if (disconnThrowable != null) {
Log.w(TAG, "gracefulDisconnect: disconnect error: " + disconnThrowable.getMessage());
logs.info("gracefulDisconnect: disconnect error: " + disconnThrowable.getMessage());
} else {
Log.i(TAG, "gracefulDisconnect: disconnected cleanly, LWT suppressed.");
logs.info("gracefulDisconnect: disconnected cleanly, LWT suppressed.");
}
// 回呼呼叫方可以安全地執行 killProcess
if (onComplete != null) onComplete.run();
if (onComplete != null)
onComplete.run();
});
});
}
private void subscribeToCommandTopic() {
if (client == null || serialNo == null) return;
if (client == null || serialNo == null)
return;
String commandTopic = "machine/" + serialNo + "/command";
client.subscribeWith()
.topicFilter(commandTopic)
.qos(MqttQos.AT_LEAST_ONCE)
.callback(publish -> {
String payloadString = new String(publish.getPayloadAsBytes(), StandardCharsets.UTF_8);
Log.i(TAG, "Received command payload: " + payloadString);
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) {
Log.e(TAG, "Error parsing command payload", e);
logs.info("Error parsing command payload: " + e.getMessage());
}
})
.send()
.whenComplete((subAck, throwable) -> {
if (throwable != null) {
Log.e(TAG, "Failed to subscribe to command topic", throwable);
logs.info("Failed to subscribe to command topic: " + throwable.getMessage());
} else {
Log.i(TAG, "Subscribed to " + commandTopic);
logs.info("Subscribed to " + commandTopic);
}
});
}
public void publishStatus(String status) {
if (client == null || !client.getState().isConnected()) return;
if (client == null || !client.getState().isConnected())
return;
String payload = "{\"status\":\"" + status + "\"}";
String topic = "machine/" + serialNo + "/status";
@ -251,15 +259,16 @@ public class MqttManager {
.send()
.whenComplete((publishResult, throwable) -> {
if (throwable != null) {
Log.e(TAG, "Failed to publish status", throwable);
logs.info("Failed to publish status: " + throwable.getMessage());
} else {
Log.d(TAG, "Status published: " + payload);
logs.info("Status published: " + payload);
}
});
}
public void publishHeartbeat(String firmwareVersion, int temperature) {
if (client == null || !client.getState().isConnected()) return;
if (client == null || !client.getState().isConnected())
return;
HeartbeatPayload payloadObj = new HeartbeatPayload(firmwareVersion, temperature);
String payload = gson.toJson(payloadObj);
@ -272,9 +281,9 @@ public class MqttManager {
.send()
.whenComplete((publishResult, throwable) -> {
if (throwable != null) {
Log.e(TAG, "Failed to publish heartbeat", throwable);
logs.info("Failed to publish heartbeat: " + throwable.getMessage());
} else {
Log.d(TAG, "Heartbeat published: " + payload);
logs.info("Heartbeat published: " + payload);
}
});
}
@ -282,13 +291,18 @@ public class MqttManager {
public void publishCommandAck(String commandId, String result, String message) {
publishCommandAck(new CommandAckPayload(commandId, result, message));
}
public void publishCommandAck(CommandAckPayload ack) {
if (client == null || !client.getState().isConnected()) return;
if (client == null || !client.getState().isConnected()) {
if (logs != null) logs.info("Cannot publish ACK: MQTT not connected");
return;
}
String payload = gson.toJson(ack);
String topic = "machine/" + serialNo + "/command/ack";
logs.info("Publishing Command ACK to [" + topic + "]: " + payload);
client.publishWith()
.topic(topic)
.payload(payload.getBytes(StandardCharsets.UTF_8))
@ -296,15 +310,16 @@ public class MqttManager {
.send()
.whenComplete((publishResult, throwable) -> {
if (throwable != null) {
Log.e(TAG, "Failed to publish command ack", throwable);
logs.info("Failed to publish command ack: " + throwable.getMessage());
} else {
Log.d(TAG, "Command ACK published: " + payload);
logs.info("Command ACK published: " + payload);
}
});
}
public void publishError(int tid, String errorCode) {
if (client == null || !client.getState().isConnected()) return;
if (client == null || !client.getState().isConnected())
return;
ErrorPayload payloadObj = new ErrorPayload(tid, errorCode);
String payload = gson.toJson(payloadObj);
@ -317,9 +332,9 @@ public class MqttManager {
.send()
.whenComplete((publishResult, throwable) -> {
if (throwable != null) {
Log.e(TAG, "Failed to publish error to MQTT", throwable);
logs.info("Failed to publish error to MQTT: " + throwable.getMessage());
} else {
Log.i(TAG, "Hardware error published to " + topic + ": " + payload);
logs.info("Hardware error published to " + topic + ": " + payload);
}
});
}
@ -334,7 +349,7 @@ public class MqttManager {
*/
public void publishTransactionFinalize(TransactionFinalizePayload payload) {
if (client == null || !client.getState().isConnected()) {
Log.e(TAG, "Cannot publish finalize: MQTT not connected");
logs.info("Cannot publish finalize: MQTT not connected");
return;
}
@ -348,9 +363,9 @@ public class MqttManager {
.send()
.whenComplete((publishResult, throwable) -> {
if (throwable != null) {
Log.e(TAG, "Failed to publish transaction finalize", throwable);
logs.info("Failed to publish transaction finalize: " + throwable.getMessage());
} else {
Log.i(TAG, "Transaction finalize published to " + topic + ": " + payloadJson);
logs.info("Transaction finalize published to " + topic + ": " + payloadJson);
}
});
}

View File

@ -33,7 +33,6 @@ import java.util.Date;
import java.util.Locale;
import java.util.UUID;
/**
* Foreground Service負責持有 WakeLock 並管理 MQTT 連線
* 確保販賣機台在關閉螢幕時也能維持通訊
@ -43,11 +42,13 @@ public class MqttService extends Service {
private static final String TAG = "MqttService";
private static final String CHANNEL_ID = "MqttServiceChannel";
private PowerManager.WakeLock wakeLock;
private com.unibuy.smartdevice.exception.Logs logs;
@Override
public void onCreate() {
super.onCreate();
Log.i(TAG, "MqttService Created");
logs = new com.unibuy.smartdevice.exception.Logs(getClass());
logs.info("MqttService Created");
// 1. 建立 Notification Channel
createNotificationChannel();
@ -71,7 +72,7 @@ public class MqttService extends Service {
if (powerManager != null) {
wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "StarCloud:MqttWakeLock");
wakeLock.acquire();
Log.i(TAG, "WakeLock acquired");
logs.info("WakeLock acquired");
}
}
@ -80,48 +81,52 @@ public class MqttService extends Service {
String machineId = intent != null ? intent.getStringExtra("machineId") : null;
String apiToken = intent != null ? intent.getStringExtra("apiToken") : null;
Log.i(TAG, "MqttService onStartCommand with machineId: " + machineId);
logs.info("MqttService onStartCommand with machineId: " + machineId);
if (machineId != null && apiToken != null) {
MqttManager mqttManager = MqttManager.getInstance();
mqttManager.setCommandListener(command -> {
String cmd = command.getCommand();
String cmdId = command.getCommand_id();
Log.i(TAG, "Received MQTT command: " + cmd + " (ID: " + cmdId + ")");
logs.info("Received MQTT command: " + cmd + " (ID: " + cmdId + ")");
try {
if ("reboot".equals(cmd)) {
Log.i(TAG, "Executing remote reboot command...");
logs.info("Executing remote reboot command...");
mqttManager.publishCommandAck(cmdId, "success", "Rebooting soon...");
// 🚩 優雅重啟ACK 發出後先 gracefulDisconnect不觸發 LWT killProcess
// 重啟到初始畫面 (HomeActivity)購物去再經 RestartActivity 釋放硬體不会再次 killProcess
new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(() ->
mqttManager.gracefulDisconnect(() -> {
new android.os.Handler(android.os.Looper.getMainLooper()).post(() -> {
Intent restartIntent = new Intent(getApplicationContext(), com.unibuy.smartdevice.HomeActivity.class);
restartIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
startActivity(restartIntent);
android.os.Process.killProcess(android.os.Process.myPid());
System.exit(0);
});
})
, 500); // 0.5 秒確保 ACK 先發出
new android.os.Handler(android.os.Looper.getMainLooper())
.postDelayed(() -> mqttManager.gracefulDisconnect(() -> {
new android.os.Handler(android.os.Looper.getMainLooper()).post(() -> {
Intent restartIntent = new Intent(getApplicationContext(),
com.unibuy.smartdevice.HomeActivity.class);
restartIntent.addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
startActivity(restartIntent);
android.os.Process.killProcess(android.os.Process.myPid());
System.exit(0);
});
}), 500); // 0.5 秒確保 ACK 先發出
} else if ("lock".equals(cmd)) {
Log.i(TAG, "Executing remote lock command...");
com.unibuy.smartdevice.database.SettingsDao settingsDao = new com.unibuy.smartdevice.database.SettingsDao(getApplicationContext());
logs.info("Executing remote lock command...");
com.unibuy.smartdevice.database.SettingsDao settingsDao = new com.unibuy.smartdevice.database.SettingsDao(
getApplicationContext());
settingsDao.setMachineLocked(true);
settingsDao.close();
mqttManager.publishCommandAck(cmdId, "success", "Machine locked");
new android.os.Handler(android.os.Looper.getMainLooper()).post(() -> {
Intent lockIntent = new Intent(getApplicationContext(), com.unibuy.smartdevice.ui.MaintenanceActivity.class);
Intent lockIntent = new Intent(getApplicationContext(),
com.unibuy.smartdevice.ui.MaintenanceActivity.class);
lockIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
startActivity(lockIntent);
});
} else if ("unlock".equals(cmd)) {
Log.i(TAG, "Executing remote unlock command...");
com.unibuy.smartdevice.database.SettingsDao settingsDao = new com.unibuy.smartdevice.database.SettingsDao(getApplicationContext());
logs.info("Executing remote unlock command...");
com.unibuy.smartdevice.database.SettingsDao settingsDao = new com.unibuy.smartdevice.database.SettingsDao(
getApplicationContext());
settingsDao.setMachineLocked(false);
settingsDao.close();
@ -129,41 +134,46 @@ public class MqttService extends Service {
// 🚩 優雅重啟 gracefulDisconnect不觸發 LWT killProcess
// 解鎖後重啟到初始畫面 (HomeActivity)
new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(() ->
mqttManager.gracefulDisconnect(() -> {
new android.os.Handler(android.os.Looper.getMainLooper()).post(() -> {
Intent restartIntent = new Intent(getApplicationContext(), com.unibuy.smartdevice.HomeActivity.class);
restartIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
startActivity(restartIntent);
android.os.Process.killProcess(android.os.Process.myPid());
System.exit(0);
});
})
, 500);
new android.os.Handler(android.os.Looper.getMainLooper())
.postDelayed(() -> mqttManager.gracefulDisconnect(() -> {
new android.os.Handler(android.os.Looper.getMainLooper()).post(() -> {
Intent restartIntent = new Intent(getApplicationContext(),
com.unibuy.smartdevice.HomeActivity.class);
restartIntent.addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
startActivity(restartIntent);
android.os.Process.killProcess(android.os.Process.myPid());
System.exit(0);
});
}), 500);
} else if ("nfc_reset".equals(cmd)) {
Log.i(TAG, "Executing remote NFC reset command...");
logs.info("Executing remote NFC reset command...");
try {
com.unibuy.smartdevice.devices.Rs232Dev comPort2 = new com.unibuy.smartdevice.devices.Rs232Dev(com.unibuy.smartdevice.MyApp.getInstance().getComPort2ByNFCPay());
com.unibuy.smartdevice.devices.DevNFCPay devNFCPay = new com.unibuy.smartdevice.devices.DevNFCPay(getApplicationContext());
com.unibuy.smartdevice.devices.Rs232Dev comPort2 = new com.unibuy.smartdevice.devices.Rs232Dev(
com.unibuy.smartdevice.MyApp.getInstance().getComPort2ByNFCPay());
com.unibuy.smartdevice.devices.DevNFCPay devNFCPay = new com.unibuy.smartdevice.devices.DevNFCPay(
getApplicationContext());
comPort2.send(devNFCPay.reset());
mqttManager.publishCommandAck(cmdId, "success", "NFC reset command sent");
} catch (Exception e) {
Log.e(TAG, "NFC reset failed: " + e.getMessage());
logs.info("NFC reset failed: " + e.getMessage());
mqttManager.publishCommandAck(cmdId, "failed", "NFC reset failed: " + e.getMessage());
}
} else if ("nfc_checkout".equals(cmd)) {
Log.i(TAG, "Executing remote NFC checkout command...");
logs.info("Executing remote NFC checkout command...");
try {
com.unibuy.smartdevice.devices.Rs232Dev comPort2 = new com.unibuy.smartdevice.devices.Rs232Dev(com.unibuy.smartdevice.MyApp.getInstance().getComPort2ByNFCPay());
com.unibuy.smartdevice.devices.DevNFCPay devNFCPay = new com.unibuy.smartdevice.devices.DevNFCPay(getApplicationContext());
com.unibuy.smartdevice.devices.Rs232Dev comPort2 = new com.unibuy.smartdevice.devices.Rs232Dev(
com.unibuy.smartdevice.MyApp.getInstance().getComPort2ByNFCPay());
com.unibuy.smartdevice.devices.DevNFCPay devNFCPay = new com.unibuy.smartdevice.devices.DevNFCPay(
getApplicationContext());
comPort2.send(devNFCPay.checkout());
mqttManager.publishCommandAck(cmdId, "success", "NFC checkout command sent");
} catch (Exception e) {
Log.e(TAG, "NFC checkout failed: " + e.getMessage());
logs.info("NFC checkout failed: " + e.getMessage());
mqttManager.publishCommandAck(cmdId, "failed", "NFC checkout failed: " + e.getMessage());
}
} else if ("change".equals(cmd)) {
Log.i(TAG, "Executing remote change command...");
logs.info("Executing remote change command...");
try {
int amount = 0;
if (command.getPayload() != null && command.getPayload().isJsonObject()) {
@ -178,33 +188,100 @@ public class MqttService extends Service {
}
int finalAmount = amount;
com.unibuy.smartdevice.tools.CashPayoutManager payoutManager = new com.unibuy.smartdevice.tools.CashPayoutManager(getApplicationContext(), new com.unibuy.smartdevice.tools.CashPayoutManager.PayoutListener() {
@Override
public void onProgress(String message) {
Log.d(TAG, "Payout Progress: " + message);
}
com.unibuy.smartdevice.tools.CashPayoutManager payoutManager = new com.unibuy.smartdevice.tools.CashPayoutManager(
getApplicationContext(),
new com.unibuy.smartdevice.tools.CashPayoutManager.PayoutListener() {
@Override
public void onProgress(String message) {
logs.debug("Payout Progress: " + message);
}
@Override
public void onSuccess() {
Log.i(TAG, "Remote payout success for amount: " + finalAmount);
mqttManager.publishCommandAck(cmdId, "success", "Payout completed: " + finalAmount);
}
@Override
public void onSuccess() {
logs.info("Remote payout success for amount: " + finalAmount);
mqttManager.publishCommandAck(cmdId, "success",
"Payout completed: " + finalAmount);
}
@Override
public void onFailure(String error) {
Log.e(TAG, "Remote payout failed: " + error);
mqttManager.publishCommandAck(cmdId, "failed", "Payout failed: " + error);
}
});
@Override
public void onFailure(String error) {
logs.info("Remote payout failed: " + error);
mqttManager.publishCommandAck(cmdId, "failed", "Payout failed: " + error);
}
});
payoutManager.startPayout(amount);
// 注意ACK 會在 PayoutListener 的回調中發送不在此處發送
} catch (Exception e) {
Log.e(TAG, "Failed to parse or start change command: " + e.getMessage());
logs.info("Failed to parse or start change command: " + e.getMessage());
mqttManager.publishCommandAck(cmdId, "failed", "Error: " + e.getMessage());
}
} else if ("update_inventory".equals(cmd)) {
logs.info("Executing update_inventory command...");
try {
int slotNo = -1;
int stock = -1;
String expiryDate = null;
String batchNo = null;
if (command.getPayload() != null && command.getPayload().isJsonObject()) {
com.google.gson.JsonObject payloadObj = command.getPayload().getAsJsonObject();
if (payloadObj.has("slot_no"))
slotNo = payloadObj.get("slot_no").getAsInt();
if (payloadObj.has("stock"))
stock = payloadObj.get("stock").getAsInt();
if (payloadObj.has("expiry_date") && !payloadObj.get("expiry_date").isJsonNull())
expiryDate = payloadObj.get("expiry_date").getAsString();
if (payloadObj.has("batch_no") && !payloadObj.get("batch_no").isJsonNull())
batchNo = payloadObj.get("batch_no").getAsString();
}
if (slotNo <= 0 || stock < 0) {
throw new Exception("Invalid parameters: slot_no=" + slotNo + ", stock=" + stock);
}
// 更新本地資料庫
int field = com.unibuy.smartdevice.devices.SlotField.VMC.getField();
com.unibuy.smartdevice.structure.SlotStructure slotData = MyApp.getInstance()
.getSlotData(field, slotNo);
if (slotData != null) {
slotData.setCount(stock);
// 更新選填欄位至 otherData
org.json.JSONObject otherData = slotData.getOtherData();
if (otherData == null)
otherData = new org.json.JSONObject();
try {
if (expiryDate != null)
otherData.put("expiry_date", expiryDate);
if (batchNo != null)
otherData.put("batch_no", batchNo);
} catch (org.json.JSONException e) {
logs.info("Failed to update JSON otherData: " + e.getMessage());
}
slotData.setOtherData(otherData);
com.unibuy.smartdevice.database.SlotsDao slotsDao = new com.unibuy.smartdevice.database.SlotsDao(
getApplicationContext());
slotsDao.deleteAll();
slotsDao.insertAll(MyApp.getInstance().getSlotMap());
slotsDao.close();
logs.info("Inventory updated: slot=" + slotNo + ", stock=" + stock);
mqttManager
.publishCommandAck(new com.unibuy.smartdevice.structure.mqtt.CommandAckPayload(
cmdId, "success", "Inventory updated successfully",
String.valueOf(stock)));
} else {
throw new Exception("Slot not found: " + slotNo);
}
} catch (Exception e) {
logs.info("Failed to update inventory: " + e.getMessage());
mqttManager.publishCommandAck(cmdId, "failed",
"Update inventory failed: " + e.getMessage());
}
} else if ("dispense".equals(cmd)) {
Log.i(TAG, "Executing remote dispense command...");
logs.info("Executing remote dispense command...");
try {
int slot = -1;
if (command.getPayload() != null && command.getPayload().isJsonObject()) {
@ -221,89 +298,40 @@ public class MqttService extends Service {
}
int finalSlot = slot;
com.unibuy.smartdevice.tools.ProductDispenseManager dispenseManager = new com.unibuy.smartdevice.tools.ProductDispenseManager(getApplicationContext(), new com.unibuy.smartdevice.tools.ProductDispenseManager.DispenseListener() {
@Override
public void onProgress(String message) {
Log.d(TAG, "Dispense Progress: " + message);
}
@Override
public void onSuccess() {
Log.i(TAG, "Remote dispense success for slot: " + finalSlot + " (cmdId: " + cmdId + ")");
mqttManager.publishCommandAck(cmdId, "success", "Product dispensed from slot: " + finalSlot);
// [修正] 使用 TransactionFinalizeBuilder 走完整交易流程確保後台有訂單紀錄
try {
// Step 1: 從記憶體中找到對應貨道的商品資料
SlotStructure slotStructure = null;
for (SlotField slotField : SlotField.values()) {
try {
slotStructure = MyApp.getInstance().getSlotData(slotField.getField(), finalSlot);
break; // 找到就停止
} catch (LogsEmptyException ignored) {
// field 找不到繼續找下一個
}
com.unibuy.smartdevice.tools.ProductDispenseManager dispenseManager = new com.unibuy.smartdevice.tools.ProductDispenseManager(
getApplicationContext(),
logs,
new com.unibuy.smartdevice.tools.ProductDispenseManager.DispenseListener() {
@Override
public void onProgress(String message) {
logs.debug("Dispense Progress: " + message);
}
if (slotStructure == null) {
Log.e(TAG, "Remote dispense finalize failed: slot " + finalSlot + " not found in memory");
return;
@Override
public void onSuccess() {
if (logs != null) logs.info("Remote dispense success for slot: " + finalSlot + " (cmdId: " + cmdId + ")");
// 依據使用者測試結果回傳簡潔的 ACK (僅含 command_id result) 即可成功更改狀態
mqttManager.publishCommandAck(new com.unibuy.smartdevice.structure.mqtt.CommandAckPayload(cmdId, "success"));
}
// Step 2: 清空並重新設定 BuyList模擬使用者點選此商品
MyApp.getInstance().getBuyList().clear();
MyApp.getInstance().getBuyList().add(
new BuyStructure(slotStructure.getField(), finalSlot, slotStructure.getProduct(), 1)
);
// Step 3: 設定 ReportFlowInfoDatapayment_type=100 代表遠端管理出貨
ReportFlowInfoStructure flowInfo = MyApp.getInstance().getReportFlowInfoData();
flowInfo.setFlowType(100); // 遠端管理出貨
flowInfo.setTotalPrice(0); // 免費出貨
flowInfo.setGiveChange(0);
flowInfo.setUsePoints(0);
flowInfo.setFlowBarCode("");
flowInfo.setFlowSendInfo(""); // 無支付請求封包
flowInfo.setFlowRequestInfo(""); // 無支付回應封包
flowInfo.setInvoiceInfo("");
// Step 4: Builder 組裝並送出 Finalize Payload
TransactionFinalizeBuilder builder = new TransactionFinalizeBuilder();
int remaining = slotStructure.getCount() - 1;
int productIdInt = 0;
try { productIdInt = Integer.parseInt(slotStructure.getProduct().getProductID()); } catch (Exception ignored) {}
builder.addDispenseRecord(finalSlot, productIdInt, 0, 0, true, remaining);
MqttManager.getInstance().publishTransactionFinalize(builder.build());
Log.i(TAG, "[Remote Dispense] Transaction finalize sent via TransactionFinalizeBuilder, slot=" + finalSlot);
// 清空 BuyList 避免影響後續正常交易
MyApp.getInstance().getBuyList().clear();
} catch (Exception e) {
Log.e(TAG, "Failed to send finalize for remote dispense: " + e.getMessage(), e);
}
}
@Override
public void onFailure(String error) {
Log.e(TAG, "Remote dispense failed: " + error);
mqttManager.publishCommandAck(cmdId, "failed", "Dispense failed: " + error);
}
});
@Override
public void onFailure(String error) {
if (logs != null) logs.info("Remote dispense failed: " + error);
mqttManager.publishCommandAck(new com.unibuy.smartdevice.structure.mqtt.CommandAckPayload(cmdId, "failed"));
}
});
dispenseManager.startDispense(slot);
} catch (Exception e) {
Log.e(TAG, "Failed to parse or start dispense command: " + e.getMessage());
logs.info("Failed to parse or start dispense command: " + e.getMessage());
mqttManager.publishCommandAck(cmdId, "failed", "Error: " + e.getMessage());
}
} else {
Log.w(TAG, "Unknown MQTT command: " + cmd);
logs.info("Unknown MQTT command: " + cmd);
mqttManager.publishCommandAck(cmdId, "failed", "Unknown command");
}
} catch (Exception e) {
Log.e(TAG, "Error executing MQTT command: " + e.getMessage(), e);
logs.info("Error executing MQTT command: " + e.getMessage());
mqttManager.publishCommandAck(cmdId, "failed", "Error: " + e.getMessage());
}
});
@ -317,10 +345,10 @@ public class MqttService extends Service {
@Override
public void onDestroy() {
super.onDestroy();
Log.i(TAG, "MqttService Destroyed");
logs.info("MqttService Destroyed");
if (wakeLock != null && wakeLock.isHeld()) {
wakeLock.release();
Log.i(TAG, "WakeLock released");
logs.info("WakeLock released");
}
}
@ -335,8 +363,7 @@ public class MqttService extends Service {
NotificationChannel serviceChannel = new NotificationChannel(
CHANNEL_ID,
"MQTT Service Channel",
NotificationManager.IMPORTANCE_LOW
);
NotificationManager.IMPORTANCE_LOW);
NotificationManager manager = getSystemService(NotificationManager.class);
if (manager != null) {
manager.createNotificationChannel(serviceChannel);

View File

@ -6,6 +6,11 @@ public class CommandAckPayload {
private String message;
private String stock;
public CommandAckPayload(String command_id, String result) {
this.command_id = command_id;
this.result = result;
}
public CommandAckPayload(String command_id, String result, String message) {
this.command_id = command_id;
this.result = result;

View File

@ -22,12 +22,12 @@ public class TransactionFinalizePayload {
* B660(取貨碼) / B670(通行碼) / B680(員工卡) 驗證成功後後台返回的 ID
* 一般現金/電子支付交易此欄位填 0
*/
private int code_id;
private String code_id;
/**
* 遠端指令 ID (僅用於 payment_type = 100)
/**
* 遠端指令 ID (僅用於 payment_type = 100)
*/
private int remote_command_id;
private String remote_command_id;
/**
* 支付類型 (Root 級別用於後端快速判斷)
@ -42,25 +42,57 @@ public class TransactionFinalizePayload {
// =========== Getters & Setters ===========
public String getAction() { return action; }
public String getAction() {
return action;
}
public String getFlow_id() { return flow_id; }
public void setFlow_id(String flow_id) { this.flow_id = flow_id; }
public String getFlow_id() {
return flow_id;
}
public int getCode_id() { return code_id; }
public void setCode_id(int code_id) { this.code_id = code_id; }
public void setFlow_id(String flow_id) {
this.flow_id = flow_id;
}
public int getRemote_command_id() { return remote_command_id; }
public void setRemote_command_id(int remote_command_id) { this.remote_command_id = remote_command_id; }
public String getCode_id() {
return code_id;
}
public int getPayment_type() { return payment_type; }
public void setPayment_type(int payment_type) { this.payment_type = payment_type; }
public void setCode_id(String code_id) {
this.code_id = code_id;
}
public OrderPayload getOrder() { return order; }
public void setOrder(OrderPayload order) { this.order = order; }
public String getRemote_command_id() {
return remote_command_id;
}
public List<DispenseRecord> getDispense() { return dispense; }
public void setDispense(List<DispenseRecord> dispense) { this.dispense = dispense; }
public void setRemote_command_id(String remote_command_id) {
this.remote_command_id = remote_command_id;
}
public int getPayment_type() {
return payment_type;
}
public void setPayment_type(int payment_type) {
this.payment_type = payment_type;
}
public OrderPayload getOrder() {
return order;
}
public void setOrder(OrderPayload order) {
this.order = order;
}
public List<DispenseRecord> getDispense() {
return dispense;
}
public void setDispense(List<DispenseRecord> dispense) {
this.dispense = dispense;
}
// =========== 內部類別訂單資訊 ===========
@ -118,50 +150,125 @@ public class TransactionFinalizePayload {
private List<ItemRecord> items;
// Getters & Setters
public String getOrder_no() { return order_no; }
public void setOrder_no(String order_no) { this.order_no = order_no; }
public String getOrder_no() {
return order_no;
}
public int getOriginal_amount() { return original_amount; }
public void setOriginal_amount(int original_amount) { this.original_amount = original_amount; }
public void setOrder_no(String order_no) {
this.order_no = order_no;
}
public int getDiscount_amount() { return discount_amount; }
public void setDiscount_amount(int discount_amount) { this.discount_amount = discount_amount; }
public int getOriginal_amount() {
return original_amount;
}
public int getTotal_amount() { return total_amount; }
public void setTotal_amount(int total_amount) { this.total_amount = total_amount; }
public void setOriginal_amount(int original_amount) {
this.original_amount = original_amount;
}
public int getPay_amount() { return pay_amount; }
public void setPay_amount(int pay_amount) { this.pay_amount = pay_amount; }
public int getDiscount_amount() {
return discount_amount;
}
public int getChange_amount() { return change_amount; }
public void setChange_amount(int change_amount) { this.change_amount = change_amount; }
public void setDiscount_amount(int discount_amount) {
this.discount_amount = discount_amount;
}
public int getPoints_used() { return points_used; }
public void setPoints_used(int points_used) { this.points_used = points_used; }
public int getTotal_amount() {
return total_amount;
}
public int getPayment_type() { return payment_type; }
public void setPayment_type(int payment_type) { this.payment_type = payment_type; }
public void setTotal_amount(int total_amount) {
this.total_amount = total_amount;
}
public int getPayment_status() { return payment_status; }
public void setPayment_status(int payment_status) { this.payment_status = payment_status; }
public int getPay_amount() {
return pay_amount;
}
public String getPayment_request() { return payment_request; }
public void setPayment_request(String payment_request) { this.payment_request = payment_request; }
public void setPay_amount(int pay_amount) {
this.pay_amount = pay_amount;
}
public String getPayment_response() { return payment_response; }
public void setPayment_response(String payment_response) { this.payment_response = payment_response; }
public int getChange_amount() {
return change_amount;
}
public String getMember_barcode() { return member_barcode; }
public void setMember_barcode(String member_barcode) { this.member_barcode = member_barcode; }
public void setChange_amount(int change_amount) {
this.change_amount = change_amount;
}
public String getInvoice_info() { return invoice_info; }
public void setInvoice_info(String invoice_info) { this.invoice_info = invoice_info; }
public int getPoints_used() {
return points_used;
}
public String getMachine_time() { return machine_time; }
public void setMachine_time(String machine_time) { this.machine_time = machine_time; }
public void setPoints_used(int points_used) {
this.points_used = points_used;
}
public List<ItemRecord> getItems() { return items; }
public void setItems(List<ItemRecord> items) { this.items = items; }
public int getPayment_type() {
return payment_type;
}
public void setPayment_type(int payment_type) {
this.payment_type = payment_type;
}
public int getPayment_status() {
return payment_status;
}
public void setPayment_status(int payment_status) {
this.payment_status = payment_status;
}
public String getPayment_request() {
return payment_request;
}
public void setPayment_request(String payment_request) {
this.payment_request = payment_request;
}
public String getPayment_response() {
return payment_response;
}
public void setPayment_response(String payment_response) {
this.payment_response = payment_response;
}
public String getMember_barcode() {
return member_barcode;
}
public void setMember_barcode(String member_barcode) {
this.member_barcode = member_barcode;
}
public String getInvoice_info() {
return invoice_info;
}
public void setInvoice_info(String invoice_info) {
this.invoice_info = invoice_info;
}
public String getMachine_time() {
return machine_time;
}
public void setMachine_time(String machine_time) {
this.machine_time = machine_time;
}
public List<ItemRecord> getItems() {
return items;
}
public void setItems(List<ItemRecord> items) {
this.items = items;
}
}
// =========== 內部類別購買商品記錄 ===========
@ -182,9 +289,17 @@ public class TransactionFinalizePayload {
this.quantity = quantity;
}
public int getProduct_id() { return product_id; }
public int getPrice() { return price; }
public int getQuantity() { return quantity; }
public int getProduct_id() {
return product_id;
}
public int getPrice() {
return price;
}
public int getQuantity() {
return quantity;
}
}
// =========== 內部類別出貨記錄 ===========
@ -214,7 +329,7 @@ public class TransactionFinalizePayload {
private String machine_time;
public DispenseRecord(String slot_no, int product_id, int amount, int points_used,
int dispense_status, int remaining_stock, String machine_time) {
int dispense_status, int remaining_stock, String machine_time) {
this.slot_no = slot_no;
this.product_id = product_id;
this.amount = amount;
@ -224,12 +339,32 @@ public class TransactionFinalizePayload {
this.machine_time = machine_time;
}
public String getSlot_no() { return slot_no; }
public int getProduct_id() { return product_id; }
public int getAmount() { return amount; }
public int getPoints_used() { return points_used; }
public int getDispense_status() { return dispense_status; }
public int getRemaining_stock() { return remaining_stock; }
public String getMachine_time() { return machine_time; }
public String getSlot_no() {
return slot_no;
}
public int getProduct_id() {
return product_id;
}
public int getAmount() {
return amount;
}
public int getPoints_used() {
return points_used;
}
public int getDispense_status() {
return dispense_status;
}
public int getRemaining_stock() {
return remaining_stock;
}
public String getMachine_time() {
return machine_time;
}
}
}

View File

@ -4,6 +4,7 @@ import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import java.util.List;
import com.unibuy.smartdevice.MyApp;
import com.unibuy.smartdevice.controller.DevController;
@ -20,22 +21,28 @@ public class ProductDispenseManager {
public interface DispenseListener {
void onProgress(String message);
void onSuccess();
void onFailure(String error);
}
private final Context context;
private final DispenseListener listener;
private final com.unibuy.smartdevice.exception.Logs logs;
private final Handler mainHandler = new Handler(Looper.getMainLooper());
private int checkRetryCount = 0;
public ProductDispenseManager(Context context, DispenseListener listener) {
public ProductDispenseManager(Context context, com.unibuy.smartdevice.exception.Logs logs,
DispenseListener listener) {
this.context = context;
this.logs = logs;
this.listener = listener;
}
public void startDispense(int slot) {
Log.i(TAG, "Starting background dispense for slot: " + slot);
if (logs != null)
logs.info("Starting background dispense for slot: " + slot);
listener.onProgress("正在檢查貨道狀態...");
DevXinYuanController controller = MyApp.getInstance().getDevXinYuanController();
@ -47,12 +54,18 @@ public class ProductDispenseManager {
// 步驟 1: 檢查貨道狀態 (CMD 01 -> VMC 02)
controller.getReadBufferByNow(new DevController.ReadBufferOnScheduler(new HandlerMain(context, null) {
@Override
protected void execute(Context context, int commandCode, String message) {}
protected void execute(Context context, int commandCode, String message) {
}
}) {
@Override
protected HandlerMain setHandlerMain() { return getSrcHandlerMain(); }
protected HandlerMain setHandlerMain() {
return getSrcHandlerMain();
}
@Override
protected Class<?> setCls() { return getClass(); }
protected Class<?> setCls() {
return getClass();
}
@Override
public void readBufferOnScheduler(DevController devController, HandlerMain handlerMain) {
@ -63,7 +76,8 @@ public class ProductDispenseManager {
for (byte[] buffer : devController.getReadBufferByMax()) {
if (buffer[0] == 0x02) { // VMC_02_CHECK_SLOT_STATUS
isCMD = true;
Log.d(TAG, "Check Slot Result: " + PortTools.showHex(buffer));
if (logs != null)
logs.debug("Check Slot Result: " + PortTools.showHex(buffer));
// 01: OK, 02: PDT_EMPTY (有些機型空也會允許出貨指令), 04: TMP_USE
if (buffer[1] == 0x01 || buffer[1] == 0x02 || buffer[1] == 0x04) {
isStatusOk = true;
@ -88,7 +102,7 @@ public class ProductDispenseManager {
if (checkRetryCount < 3) {
checkRetryCount++;
controller.addSendBuffer(controller.getDevXinYuan().checkSlot(slot));
controller.getReadBufferByNow(this, 1000);
controller.getReadBuffer(this, 1000);
} else {
mainHandler.post(() -> listener.onFailure("下位機檢查貨道無回應"));
}
@ -102,60 +116,110 @@ public class ProductDispenseManager {
private void executeActualDispense(int slot) {
listener.onProgress("貨道正常,正在發送出貨指令...");
DevXinYuanController controller = MyApp.getInstance().getDevXinYuanController();
long startTime = System.currentTimeMillis();
long timeoutMillis = 60000; // 60 秒超時
// 步驟 2: 發送出貨指令並監聽狀態 (CMD 06 -> VMC 04)
controller.getReadBufferByNow(new DevController.ReadBufferOnScheduler(new HandlerMain(context, null) {
@Override
protected void execute(Context context, int commandCode, String message) {}
protected void execute(Context context, int commandCode, String message) {
}
}) {
@Override
protected HandlerMain setHandlerMain() { return getSrcHandlerMain(); }
protected HandlerMain setHandlerMain() {
return getSrcHandlerMain();
}
@Override
protected Class<?> setCls() { return getClass(); }
protected Class<?> setCls() {
return getClass();
}
@Override
public void readBufferOnScheduler(DevController devController, HandlerMain handlerMain) {
boolean isCMD = false;
boolean isVmc04Found = false;
boolean isSuccess = false;
boolean isFailed = false;
String errorDetail = "";
for (byte[] buffer : devController.getReadBufferByMax()) {
if (buffer[0] == 0x04) { // VMC_04_BUY_STATUS
isCMD = true;
Log.d(TAG, "Dispense Status: " + PortTools.showHex(buffer));
// 02: OK, 24: PLIS_BOX (可能是某些特定機型的成功代碼)
// 讀取所有緩衝區中的 VMC_04 訊息
// 注意buffer 中可能同時存在 VMC_11VMC_23 等其他訊號
// 必須只判斷 VMC_04 04 的封包一律忽略並繼續輪詢
List<byte[]> buffers = devController.getReadBufferByMax();
for (byte[] buffer : buffers) {
if (buffer.length >= 2 && buffer[0] == 0x04) { // VMC_04_BUY_STATUS
isVmc04Found = true;
if (logs != null)
logs.info("Dispense Status Received: " + PortTools.showHex(buffer));
// 02: OK, 24: PLIS_BOX
if (buffer[1] == 0x02 || buffer[1] == 0x24) {
if (logs != null)
logs.info("Dispense SUCCESS detected from VMC_04: " + PortTools.showHex(buffer));
isSuccess = true;
break;
}
}
// 01: NOW (處理中), 10: PLATFORM_UP, 11: PLATFORM_DOWN
else if (buffer[1] == 0x01 || buffer[1] == 0x10 || buffer[1] == 0x11) {
continue;
}
else {
if (logs != null)
logs.info("Machine busy (status " + String.format("%02X", buffer[1])
+ "), continuing poll...");
// 繼續掃描其他 buffer
} else {
if (logs != null)
logs.info("Dispense FAILED detected from VMC_04: " + PortTools.showHex(buffer));
isFailed = true;
errorDetail = String.format("%02X", buffer[1]);
break;
}
}
// VMC_04 的封包 ( VMC_11_GIVE_PRODUCT_INFO, VMC_23_NOW_GET_TOTAL) 一律跳過
}
if (isSuccess) {
mainHandler.post(() -> {
updateLocalStock(slot);
listener.onSuccess();
});
} else if (isFailed) {
// Error during CMD 04: construct error code e.g. "0403"
String errCode = String.format("04%s", errorDetail);
MqttManager.getInstance().publishError(slot, errCode);
String finalErrorDetail = errorDetail;
mainHandler.post(() -> listener.onFailure("出貨失敗,錯誤代碼: " + finalErrorDetail));
} else {
// 繼續輪詢直到成功或失敗
controller.getReadBufferByNow(this, 1000);
// isVmc04Found=false VMC_04 仍在處理中 繼續輪詢
// 重要即使 buffers 非空只要沒有明確的成功/失敗 VMC_04就必須繼續輪詢
// 之前的 bugbuffers VMC_11/VMC_23 時不為空導致 else 分支被跳過輪詢靜默終止
if (logs != null) {
if (isVmc04Found) {
logs.info("VMC_04 still busy, rescheduling poll in 1s...");
} else {
logs.info("No VMC_04 in buffer yet, rescheduling poll in 1s...");
}
}
// 檢查是否超時
if (System.currentTimeMillis() - startTime > timeoutMillis) {
if (logs != null)
logs.info("ERROR: Dispense polling timeout after " + (timeoutMillis / 1000) + "s");
MqttManager.getInstance().publishError(slot, "TIMEOUT");
mainHandler.post(() -> listener.onFailure("出貨超時 (60s)"));
return;
}
// 使用 mainHandler.postDelayed 延遲 1 秒後重新輪詢
// 重要此處改用 getReadBuffer 而非 getReadBufferByNow
// getReadBufferByNow 會清空 ReadBuffer 0402 訊號剛好在此時抵達會被抹除
final DevController.ReadBufferOnScheduler self = this;
mainHandler.postDelayed(() -> {
try {
devController.getReadBuffer(self, 500);
} catch (Exception e) {
// SchedulerAbstract 排程失敗時確保仍能回報結果觸發 ACK
if (logs != null)
logs.info("ERROR: Polling reschedule failed: " + e.getMessage());
mainHandler.post(() -> listener.onFailure("輪詢排程異常"));
}
}, 100);
}
}
}, 1000);
@ -172,16 +236,19 @@ public class ProductDispenseManager {
if (currentCount > 0) {
int newCount = currentCount - 1;
slotData.setCount(newCount);
Log.i(TAG, "Updated slot " + slot + " count: " + currentCount + " -> " + newCount);
if (logs != null)
logs.info("Updated slot " + slot + " count: " + currentCount + " -> " + newCount);
// 儲存至資料庫
com.unibuy.smartdevice.database.SlotsDao slotsDao = new com.unibuy.smartdevice.database.SlotsDao(context);
com.unibuy.smartdevice.database.SlotsDao slotsDao = new com.unibuy.smartdevice.database.SlotsDao(
context);
slotsDao.deleteAll();
slotsDao.insertAll(MyApp.getInstance().getSlotMap());
slotsDao.close();
}
} catch (Exception e) {
Log.e(TAG, "Failed to update local stock: " + e.getMessage());
if (logs != null)
logs.info("ERROR: Failed to update local stock: " + e.getMessage());
}
}
}

View File

@ -39,6 +39,9 @@ public class TransactionFinalizeBuilder {
/** 累積的出貨結果清單 */
private final List<TransactionFinalizePayload.DispenseRecord> dispenseRecords = new ArrayList<>();
private String codeId = "0";
private String remoteCommandId = "0";
public TransactionFinalizeBuilder() {
this.orderMachineTime = SDF.format(new Date());
// 在進入出貨流程時立即生成本次交易流水號一次交易只生成一次
@ -46,6 +49,14 @@ public class TransactionFinalizeBuilder {
Log.i(TAG, "TransactionFinalizeBuilder created, flow_id=" + this.flowId);
}
public void setCodeId(String codeId) {
this.codeId = codeId;
}
public void setRemoteCommandId(String remoteCommandId) {
this.remoteCommandId = remoteCommandId;
}
/**
* 加入一筆出貨結果記錄每個貨道出貨後呼叫一次
*
@ -87,16 +98,21 @@ public class TransactionFinalizeBuilder {
payload.setFlow_id(flowId);
// --- code_id特殊驗證碼 ID (B660/B670/B680 取得) ---
// 一般現金/電子支付交易為 0
String accessCodeId = MyApp.getInstance().getReportAccessCodeStructure().getAccessCodeId();
try {
payload.setCode_id(accessCodeId != null && !accessCodeId.isEmpty()
? Integer.parseInt(accessCodeId) : 0);
} catch (NumberFormatException e) {
Log.w(TAG, "Cannot parse code_id: " + accessCodeId);
payload.setCode_id(0);
// 一般現金/電子支付交易為 "0"
if (this.codeId != null && !this.codeId.equals("0")) {
payload.setCode_id(this.codeId);
} else {
String accessCodeId = MyApp.getInstance().getReportAccessCodeStructure().getAccessCodeId();
if (accessCodeId != null && !accessCodeId.isEmpty()) {
payload.setCode_id(accessCodeId);
} else {
payload.setCode_id("0");
}
}
// --- remote_command_id遠端指令 ID ---
payload.setRemote_command_id(this.remoteCommandId);
// --- payment_type支付類型 (Root 級別) ---
payload.setPayment_type(flowInfo.getFlowType());