[FIX] 修復 MQTT 監聽器與機台連線狀態同步邏輯

1. 補齊 ListenMqttQueue 中的 Job 類別引用,修復 mqtt-worker 崩潰重啟問題。
2. 實作 Go Gateway 的 Client ID 解析邏輯,正確將 SC_{serial}_{random} 格式還原為機台編號。
3. 修復  系統主題解析與 Laravel ProcessStatus Job 對齊,實現毫秒級連線狀態同步。
4. 在 ProcessStatus Job 中增加第二層 serial_no 清理保險,提高系統容錯。
5. 更新機台列表 UI 狀態顯示邏輯,支援 在線/斷線/故障 三態顯示與多語系。
6. 更新 MQTT 通訊規範 (Skill) 文件,確保全域開發定義的一致性。
7. 同步更新 zh_TW.json, en.json, ja.json 翻譯文件。
This commit is contained in:
sky121113 2026-04-21 12:38:42 +08:00
parent f4f2ca9175
commit 32bc13ac1b
17 changed files with 410 additions and 196 deletions

View File

@ -89,11 +89,12 @@ public function handle(MachineService $service): void
> - **MQTT 端點**[MQTT 即時通訊與 Topic 規範](file:///home/mama/projects/star-cloud/.agents/skills/mqtt-communication-specs/SKILL.md) > - **MQTT 端點**[MQTT 即時通訊與 Topic 規範](file:///home/mama/projects/star-cloud/.agents/skills/mqtt-communication-specs/SKILL.md)
### 常見端點處理模式 ### 常見端點處理模式
1. **B010 (心跳)**:高頻點,走 **MQTT 管線** (`machine/+/heartbeat`)。更新 `last_heard_at` 與感測器快照。 1. **$SYS Events**:由 Broker 自動觸發,走 **MQTT 管線**。用於毫秒級更新機台的 `online/offline` 狀態與 `last_heartbeat_at`
2. **B013 (異常)**:事件驅動點,走 **MQTT 管線** (`machine/+/error`)。寫入 `machine_logs` 並觸發告警。 2. **B010 (心跳)**:高頻點,走 **MQTT 管線** (`machine/+/heartbeat`)。更新 `last_heartbeat_at` 與感測器快照。
3. **B600 (交易)**:高價值點,走 **MQTT 管線** (`machine/+/transaction`)。建立 `Transaction` 紀錄並支援重試。 3. **B013 (異常)**:事件驅動點,走 **MQTT 管線** (`machine/+/error`)。寫入 `machine_logs` 並觸發告警。
4. **B012 (商品同步)**:大資料量,走 **HTTP 管線**。應確保 Service 層具備緩存 (Cache) 機制。 4. **B600 (交易)**:高價值點,走 **MQTT 管線** (`machine/+/transaction`)。建立 `Transaction` 紀錄並支援重試。
5. **B055 (遠端出貨)**:雲端下發指令,走 **MQTT 下行管線** (`machine/{id}/command`)。 5. **B012 (商品同步)**:大資料量,走 **HTTP 管線**。應確保 Service 層具備緩存 (Cache) 機制。
6. **B055 (遠端出貨)**:雲端下發指令,走 **MQTT 下行管線** (`machine/{id}/command`)。
--- ---

View File

@ -22,11 +22,13 @@ description: 定義 Star Cloud 與終端機台 (IoT) 之間的 MQTT 全域通訊
| WebSocket | 8083 | EMQX 原生 WS 監聽 | NPM 反向代理目標 | | WebSocket | 8083 | EMQX 原生 WS 監聽 | NPM 反向代理目標 |
| **WSS (公網)** | **443** | App 端透過 NPM 反代連線 | **Android / iOS App** | | **WSS (公網)** | **443** | App 端透過 NPM 反代連線 | **Android / iOS App** |
### 1.2 身份認證 (Authentication) ### 1.2 身份認證與識別 (Authentication & Identity)
機台連線時必須提供以下憑據: 機台連線時必須提供以下憑據:
- **Username**:機台編號 (`serial_no`),例如 `SN00001` - **Username**:機台編號 (`serial_no`),例如 `SN00001`
- **Password**:機台 `api_token` 的 SHA256 雜湊值,由 B014 API 取得。 - **Password**:機台 `api_token` 的 SHA256 雜湊值,由 B014 API 取得。
- **Client ID**:建議格式為 `SC_{serial_no}_{random_suffix}`,確保唯一性。 - **Client ID**:格式建議為 **`SC_{serial_no}_{random_suffix}`**。
- **解析邏輯**:系統接收連線事件時,會自動移除 `SC_` 前綴,並移除第一個底線後的所有隨機字串,還原出純淨的 `serial_no`
- **範例**`SC_M0408_pndk` ➜ 解析為 `M0408`
**EMQX 認證機制** **EMQX 認證機制**
- **資料結構**Redis Hash (`HSET`)。Key 格式為 `{prefix}machine_auth:{username}`,包含 `password` 欄位。 - **資料結構**Redis Hash (`HSET`)。Key 格式為 `{prefix}machine_auth:{username}`,包含 `password` 欄位。
@ -36,7 +38,14 @@ description: 定義 Star Cloud 與終端機台 (IoT) 之間的 MQTT 全域通訊
> [!CAUTION] > [!CAUTION]
> **嚴禁使用 Redis String (`SET`) 儲存認證資料**。EMQX 5.x 的 Redis Auth Plugin 僅支援 `HMGET` 讀取 Hash 結構。若誤用 String認證將永遠失敗。 > **嚴禁使用 Redis String (`SET`) 儲存認證資料**。EMQX 5.x 的 Redis Auth Plugin 僅支援 `HMGET` 讀取 Hash 結構。若誤用 String認證將永遠失敗。
### 1.3 Go Gateway 認證 ### 1.3 連線狀態即時同步 ($SYS Events)
系統透過訂閱 EMQX 的系統主題來實現「毫秒級」的連線狀態更新,不需等待心跳包:
- **訂閱主題**
- `$SYS/brokers/+/clients/+/connected` ➜ 標記為 `online`
- `$SYS/brokers/+/clients/+/disconnected` ➜ 標記為 `offline`
- **處理流程**Go Gateway 接收 ➜ 解析 Client ID 為 Serial No ➜ 推送 `type: status` 至 Redis ➜ Laravel `ProcessStatus` Job 更新資料庫與 `last_heartbeat_at`
### 1.4 Go Gateway 認證
- **Username**`star-cloud-gateway` (固定值)。 - **Username**`star-cloud-gateway` (固定值)。
- **Password**:與 Redis Hash `machine_auth:star-cloud-gateway` 中的 `password` 欄位對應。 - **Password**:與 Redis Hash `machine_auth:star-cloud-gateway` 中的 `password` 欄位對應。
- Go Gateway 的認證資料須透過 Laravel 側寫入 Redis。 - Go Gateway 的認證資料須透過 Laravel 側寫入 Redis。
@ -91,7 +100,9 @@ Mqtt3Client client = MqttClient.builder()
| 主題名稱 (Topic) | 方向 | QoS | 用途說明 | | 主題名稱 (Topic) | 方向 | QoS | 用途說明 |
| :--- | :--- | :--- | :--- | | :--- | :--- | :--- | :--- |
| `machine/{serial_no}/heartbeat` | 設備 ➜ 雲端 | 0 | 每 10 秒上報心跳、溫度、目前的頁面碼。 | | `machine/{serial_no}/heartbeat` | 設備 ➜ 雲端 | 0 | 每 10 秒上報心跳、溫度、目前的頁面碼 (標準大包裝)。 |
| `machine/{serial_no}/temperature` | 設備 ➜ 雲端 | 0 | **溫度即時更新**。支援純數值 Payload (如 25.5)。 |
| `machine/{serial_no}/firmware_version` | 設備 ➜ 雲端 | 1 | **韌體版本上報**。變更時發送,支援純字串 (如 1.2.0)。 |
| `machine/{serial_no}/error` | 設備 ➜ 雲端 | 1 | 發生硬體故障、卡貨或門未關時立即上報。 | | `machine/{serial_no}/error` | 設備 ➜ 雲端 | 1 | 發生硬體故障、卡貨或門未關時立即上報。 |
| `machine/{serial_no}/transaction` | 設備 ➜ 雲端 | 1 | 交易完成、出貨結果的回報。 | | `machine/{serial_no}/transaction` | 設備 ➜ 雲端 | 1 | 交易完成、出貨結果的回報。 |
| `machine/{serial_no}/command` | 雲端 ➜ 設備 | 1 | 雲端下發的即時指令(出貨、更新、重啟)。 | | `machine/{serial_no}/command` | 雲端 ➜ 設備 | 1 | 雲端下發的即時指令(出貨、更新、重啟)。 |
@ -146,6 +157,17 @@ Mqtt3Client client = MqttClient.builder()
- `reload_products`: 重新同步商品 (B012)。 - `reload_products`: 重新同步商品 (B012)。
- `dispense`: 遠端出貨指令 (B055)。 - `dispense`: 遠端出貨指令 (B055)。
### 3.4 屬性上報 (Attribute Updates)
針對特定的屬性 Topic支援 **純文字/純數值 (Raw Value)** 格式以降低機台解析負荷。
- **溫度上報** (`machine/{sn}/temperature`)
- Payload: `26.5` (純數字)
- **版本上報** (`machine/{sn}/firmware_version`)
- Payload: `1.2.5` (純字串)
> [!TIP]
> 雲端現在支援「傳統 JSON」與「簡潔 Raw Value」兩種發報模式。推薦使用新式 Attribute Topic 進行特定欄位更新。
--- ---
## 4. 安全與 QOS 規範 ## 4. 安全與 QOS 規範

View File

@ -8,6 +8,7 @@ use Illuminate\Support\Facades\Log;
use App\Jobs\Machine\ProcessHeartbeat; use App\Jobs\Machine\ProcessHeartbeat;
use App\Jobs\Machine\ProcessMachineError; use App\Jobs\Machine\ProcessMachineError;
use App\Jobs\Machine\ProcessTransaction; use App\Jobs\Machine\ProcessTransaction;
use App\Jobs\Machine\ProcessStatus;
class ListenMqttQueue extends Command class ListenMqttQueue extends Command
{ {
@ -37,7 +38,7 @@ class ListenMqttQueue extends Command
try { try {
// BLPOP returns [key, value]. Timeout set to 30s to avoid persistent connection read errors. // BLPOP returns [key, value]. Timeout set to 30s to avoid persistent connection read errors.
$result = Redis::blpop($queueKey, 30); $result = Redis::blpop($queueKey, 30);
if (!$result || !isset($result[1])) { if (!$result || !isset($result[1])) {
continue; continue;
} }
@ -68,13 +69,28 @@ class ListenMqttQueue extends Command
$payload = $data['payload'] ?? []; $payload = $data['payload'] ?? [];
if (empty($serialNo)) { if (empty($serialNo)) {
Log::warning("MQTT Listen: Missing serial_no in message", ['data' => $data]); Log::warning("MQTT Listen: Missing serialNo in message", [
'type' => $type,
'data' => $data,
'raw_payload' => $payload
]);
return; return;
} }
switch ($type) { switch ($type) {
case 'temperature':
case 'firmware_version':
// 將單一屬性包裹成 ProcessHeartbeat 認識的格式
Log::debug("MQTT Listen: Dispatching attribute update", ['serial_no' => $serialNo, 'type' => $type, 'payload' => $payload]);
ProcessHeartbeat::dispatch($serialNo, [$type => $payload]);
break;
case 'heartbeat': case 'heartbeat':
ProcessHeartbeat::dispatch($serialNo, $payload); // 確保 $payload 是陣列,若機台誤傳純數值則包裹之
$finalPayload = is_array($payload) ? $payload : ['raw_data' => $payload];
ProcessHeartbeat::dispatch($serialNo, $finalPayload);
break;
case 'status':
ProcessStatus::dispatch($serialNo, $payload);
break; break;
case 'error': case 'error':
ProcessMachineError::dispatch($serialNo, $payload); ProcessMachineError::dispatch($serialNo, $payload);

View File

@ -23,73 +23,10 @@ class MachineController extends Controller
public function heartbeat(Request $request) public function heartbeat(Request $request)
{ {
$machine = $request->get('machine'); $machine = $request->get('machine');
$data = $request->except(['machine', 'key']); // 排除 Middleware 注入的 Model 物件與認證 key $data = $request->except(['machine', 'key']);
// === 狀態異動觸發 (Redis 快取免查 DB) === // 異步處理狀態更新 (現在日誌比對統一移至 ProcessHeartbeat)
$cacheKey = "machine:{$machine->serial_no}:state"; \App\Jobs\Machine\ProcessHeartbeat::dispatch($machine->serial_no, $data);
$oldState = Cache::get($cacheKey);
$currentPage = $data['current_page'] ?? null;
$doorStatus = $data['door_status'] ?? null;
$firmwareVersion = $data['firmware_version'] ?? null;
$model = $data['model'] ?? null;
if ($currentPage !== null || $doorStatus !== null || $firmwareVersion !== null || $model !== null) {
// 更新目前狀態到 Redis (保存 1 天)
$newState = $oldState ?? [];
if ($currentPage !== null) $newState['current_page'] = $currentPage;
if ($doorStatus !== null) $newState['door_status'] = $doorStatus;
if ($firmwareVersion !== null) $newState['firmware_version'] = $firmwareVersion;
if ($model !== null) $newState['model'] = $model;
Cache::put($cacheKey, $newState, 86400);
// 若有歷史紀錄才進行比對 (避開 Cache Miss 造成的雪崩)
if ($oldState !== null) {
// 1. 判斷頁面是否變更
if ($currentPage !== null && (string)$currentPage !== (string)($oldState['current_page'] ?? '')) {
// 只記錄「絕對狀態」,配合 lang 中 "Page X" 的翻譯
ProcessStateLog::dispatch($machine->id, $machine->company_id, "Page {$currentPage}", 'info');
}
// 2. 判斷門禁是否變更 (0: 關閉, 1: 開啟)
if ($doorStatus !== null && (string)$doorStatus !== (string)($oldState['door_status'] ?? '')) {
$doorMessage = $doorStatus == 1 ? "Door Opened" : "Door Closed";
$doorLevel = 'info'; // 不論開關門皆為 info避免觸發異常狀態
ProcessStateLog::dispatch($machine->id, $machine->company_id, $doorMessage, $doorLevel);
}
// 3. 判斷韌體版本是否變更
if ($firmwareVersion !== null && (string)$firmwareVersion !== (string)($oldState['firmware_version'] ?? '')) {
$oldVersion = $oldState['firmware_version'] ?? 'Unknown';
// 直接在 Controller 進行翻譯並填值,確保儲存到 DB 的是最終正確字串
$versionMessage = __("Firmware updated to :version", ['version' => $firmwareVersion]);
ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
$versionMessage,
'info',
['old' => $oldVersion, 'new' => $firmwareVersion]
);
}
// 4. 判斷型號是否變更
if ($model !== null && (string)$model !== (string)($oldState['model'] ?? '')) {
$oldModel = $oldState['model'] ?? 'Unknown';
$modelMessage = __("Model changed to :model", ['model' => $model]);
ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
$modelMessage,
'info',
['old' => $oldModel, 'new' => $model]
);
}
}
}
// 異步處理狀態更新
ProcessHeartbeat::dispatch($machine->serial_no, $data);
// 取出待處理指令 // 取出待處理指令
$command = \App\Models\Machine\RemoteCommand::where('machine_id', $machine->id) $command = \App\Models\Machine\RemoteCommand::where('machine_id', $machine->id)
@ -145,7 +82,7 @@ class MachineController extends Controller
'status' => $status 'status' => $status
], 202); // 202 Accepted ], 202); // 202 Accepted
} }
/** /**
* B055: Get Remote Dispense Queue (POST/GET) * B055: Get Remote Dispense Queue (POST/GET)
* 用於獲取雲端下發的遠端出貨指令詳情。 * 用於獲取雲端下發的遠端出貨指令詳情。
@ -198,13 +135,13 @@ class MachineController extends Controller
return response()->json([ return response()->json([
'success' => false, 'success' => false,
'code' => 404, 'code' => 404,
'message' => 'Command not found' 'message' => __('Command not found')
], 404); ], 404);
} }
// 更新指令狀態 (0 代表成功) // 更新指令狀態 (0 代表成功)
$status = ($type == '0') ? 'success' : 'failed'; $status = ($type == '0') ? 'success' : 'failed';
$payloadUpdates = [ $payloadUpdates = [
'reported_stock' => $stockReported, 'reported_stock' => $stockReported,
'report_type' => $type 'report_type' => $type
@ -219,24 +156,24 @@ class MachineController extends Controller
if ($slot) { if ($slot) {
$oldStock = $slot->stock; $oldStock = $slot->stock;
// 若 APP 回傳的庫存大於 0 則使用回傳值,否則執行扣減 // 若 APP 回傳的庫存大於 0 則使用回傳值,否則執行扣減
$newStock = (int)($stockReported ?? 0); $newStock = (int) ($stockReported ?? 0);
if ($newStock <= 0 && $oldStock > 0) { if ($newStock <= 0 && $oldStock > 0) {
$newStock = $oldStock - 1; $newStock = $oldStock - 1;
} }
$finalStock = max(0, $newStock); $finalStock = max(0, $newStock);
$slot->update(['stock' => $finalStock]); $slot->update(['stock' => $finalStock]);
// 紀錄庫存變化供前端顯示 // 紀錄庫存變化供前端顯示
$payloadUpdates['old_stock'] = $oldStock; $payloadUpdates['old_stock'] = $oldStock;
$payloadUpdates['new_stock'] = $finalStock; $payloadUpdates['new_stock'] = $finalStock;
// 記錄狀態變化
ProcessStateLog::dispatch( ProcessStateLog::dispatch(
$machine->id, $machine->id,
$machine->company_id, $machine->company_id,
"Remote dispense successful for slot {$slotNo}", "Remote dispense successful for slot :slot",
'info' 'info',
['slot' => $slotNo]
); );
} }
} }
@ -250,7 +187,7 @@ class MachineController extends Controller
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'code' => 200, 'code' => 200,
'message' => 'Result reported' 'message' => __('Result reported')
]); ]);
} }
@ -268,7 +205,7 @@ class MachineController extends Controller
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'code' => 200, 'code' => 200,
'message' => 'Restock report accepted', 'message' => __('Restock report accepted'),
'status' => '49' 'status' => '49'
], 202); ], 202);
} }
@ -282,7 +219,7 @@ class MachineController extends Controller
public function getSlots(Request $request) public function getSlots(Request $request)
{ {
$machine = $request->get('machine'); $machine = $request->get('machine');
// 依貨道編號排序 (Sorted by slot_no as requested) // 依貨道編號排序 (Sorted by slot_no as requested)
$slots = $machine->slots()->with('product')->orderBy('slot_no')->get(); $slots = $machine->slots()->with('product')->orderBy('slot_no')->get();
@ -292,7 +229,7 @@ class MachineController extends Controller
->where('command_type', 'reload_stock') ->where('command_type', 'reload_stock')
->whereIn('status', ['pending', 'sent']) ->whereIn('status', ['pending', 'sent'])
->update([ ->update([
'status' => 'success', 'status' => 'success',
'executed_at' => now(), 'executed_at' => now(),
'note' => __('Inventory synced with machine') 'note' => __('Inventory synced with machine')
]); ]);
@ -303,12 +240,12 @@ class MachineController extends Controller
'data' => $slots->map(function ($slot) { 'data' => $slots->map(function ($slot) {
return [ return [
'tid' => $slot->slot_no, 'tid' => $slot->slot_no,
'num' => (int)$slot->stock, 'num' => (int) $slot->stock,
'expiry_date' => $slot->expiry_date ? $slot->expiry_date->format('Y-m-d') : null, 'expiry_date' => $slot->expiry_date ? $slot->expiry_date->format('Y-m-d') : null,
'batch_no' => $slot->batch_no, 'batch_no' => $slot->batch_no,
// 保留原始欄位以供除錯或未來擴充 // 保留原始欄位以供除錯或未來擴充
'product_id' => $slot->product_id, 'product_id' => $slot->product_id,
'capacity' => $slot->max_stock, 'capacity' => $slot->max_stock,
'status' => $slot->is_active ? '1' : '0', 'status' => $slot->is_active ? '1' : '0',
]; ];
}) })
@ -460,7 +397,7 @@ class MachineController extends Controller
return response()->json([ return response()->json([
'success' => false, 'success' => false,
'code' => 403, 'code' => 403,
'message' => 'Unauthorized: Account not found', 'message' => __('Unauthorized: Account not found'),
'status' => '' 'status' => ''
], 403); ], 403);
} }
@ -482,7 +419,7 @@ class MachineController extends Controller
return response()->json([ return response()->json([
'success' => false, 'success' => false,
'code' => 403, 'code' => 403,
'message' => 'Unauthorized: Account not authorized for this machine', 'message' => __('Unauthorized: Account not authorized for this machine'),
'status' => '' 'status' => ''
], 403); ], 403);
} }
@ -512,7 +449,7 @@ class MachineController extends Controller
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'code' => 200, 'code' => 200,
'message' => 'Slot report synchronized success', 'message' => __('Slot report synchronized success'),
'status' => '49' 'status' => '49'
]); ]);
} }
@ -572,7 +509,7 @@ class MachineController extends Controller
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'code' => 200, 'code' => 200,
'message' => 'Error report accepted', 'message' => __('Error report accepted'),
], 202); // 202 Accepted ], 202); // 202 Accepted
} }
@ -594,7 +531,7 @@ class MachineController extends Controller
return response()->json([ return response()->json([
'success' => false, 'success' => false,
'code' => 404, 'code' => 404,
'message' => 'Machine not found' 'message' => __('Machine not found')
], 404); ], 404);
} }
@ -606,7 +543,7 @@ class MachineController extends Controller
$data = [ $data = [
't050v01' => $machine->serial_no, 't050v01' => $machine->serial_no,
'api_token' => $machine->api_token, // 向 App 核發正式通訊 Token 'api_token' => $machine->api_token, // 向 App 核發正式通訊 Token
// 玉山支付 // 玉山支付
't050v41' => $paymentSettings['esun_store_id'] ?? '', 't050v41' => $paymentSettings['esun_store_id'] ?? '',
't050v42' => $paymentSettings['esun_term_id'] ?? '', 't050v42' => $paymentSettings['esun_term_id'] ?? '',
@ -622,7 +559,7 @@ class MachineController extends Controller
'TP_APP_ID' => $paymentSettings['tp_app_id'] ?? '', 'TP_APP_ID' => $paymentSettings['tp_app_id'] ?? '',
'TP_APP_KEY' => $paymentSettings['tp_app_key'] ?? '', 'TP_APP_KEY' => $paymentSettings['tp_app_key'] ?? '',
'TP_PARTNER_KEY' => $paymentSettings['tp_partner_key'] ?? '', 'TP_PARTNER_KEY' => $paymentSettings['tp_partner_key'] ?? '',
// 各類行動支付特店 ID // 各類行動支付特店 ID
'TP_LINE_MERCHANT_ID' => $paymentSettings['tp_line_merchant_id'] ?? '', 'TP_LINE_MERCHANT_ID' => $paymentSettings['tp_line_merchant_id'] ?? '',
'TP_PS_MERCHANT_ID' => $paymentSettings['tp_ps_merchant_id'] ?? '', 'TP_PS_MERCHANT_ID' => $paymentSettings['tp_ps_merchant_id'] ?? '',

View File

@ -20,10 +20,10 @@ class ProcessHeartbeat implements ShouldQueue
/** /**
* Create a new job instance. * Create a new job instance.
*/ */
public function __construct(string $serialNo, array $payload) public function __construct(string $serialNo, $payload)
{ {
$this->serialNo = $serialNo; $this->serialNo = $serialNo;
$this->payload = $payload; $this->payload = (array) $payload;
} }
/** /**
@ -39,21 +39,79 @@ class ProcessHeartbeat implements ShouldQueue
} }
// 更新機台狀態 // 更新機台狀態
Log::debug("ProcessHeartbeat: Attempting to set status to online for {$this->serialNo}");
$updateData = [ $updateData = [
'last_heartbeat_at' => now(), 'last_heartbeat_at' => now(),
'status' => 'online',
]; ];
// 根據 payload 動態更新欄位 // === 狀態異動觸發 (Redis 快取,讓 MQTT 也支援日誌) ===
if (isset($this->payload['current_page'])) { $cacheKey = "machine:{$this->serialNo}:state";
$updateData['current_page'] = $this->payload['current_page']; $oldState = \Illuminate\Support\Facades\Cache::get($cacheKey);
} $newState = $oldState ?? [];
if (isset($this->payload['temperature'])) {
$updateData['temperature'] = $this->payload['temperature']; // 1. 韌體版本比對 (有改才傳,有改才記)
}
if (isset($this->payload['firmware_version'])) { if (isset($this->payload['firmware_version'])) {
$updateData['firmware_version'] = $this->payload['firmware_version']; $fv = (string) $this->payload['firmware_version'];
if (!isset($oldState['firmware_version']) || (string)$oldState['firmware_version'] !== $fv) {
\App\Jobs\Machine\ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
"Firmware updated to :version",
'info',
['version' => $fv, 'old' => $oldState['firmware_version'] ?? 'Unknown']
);
}
$updateData['firmware_version'] = $fv;
$newState['firmware_version'] = $fv;
} }
// 2. 溫度更新 (智慧過濾日誌)
if (isset($this->payload['temperature'])) {
$temp = $this->payload['temperature'];
$updateData['temperature'] = $temp;
// 讀取最後一次記錄日誌的溫度與時間
$tempLogCacheKey = "machine:{$this->serialNo}:last_temp_log";
$lastLogEntry = \Illuminate\Support\Facades\Cache::get($tempLogCacheKey);
$shouldLog = false;
if (!$lastLogEntry) {
$shouldLog = true;
} else {
$lastValue = (float) $lastLogEntry['value'];
$lastAt = \Carbon\Carbon::parse($lastLogEntry['at']);
// 條件 A: 變動超過 3°C
if (abs($temp - $lastValue) >= 3.0) {
$shouldLog = true;
}
// 條件 B: 距離上次日誌超過 4 小時
if ($lastAt->diffInHours(now()) >= 4) {
$shouldLog = true;
}
}
if ($shouldLog) {
\App\Jobs\Machine\ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
"Temperature reported: :temp°C",
'info',
['temp' => $temp]
);
// 更新快取,保留 7 天
\Illuminate\Support\Facades\Cache::put($tempLogCacheKey, [
'value' => $temp,
'at' => now()->toDateTimeString()
], 604800);
}
}
// 更新快取
\Illuminate\Support\Facades\Cache::put($cacheKey, $newState, 86400);
$machine->update($updateData); $machine->update($updateData);
} }
} }

View File

@ -42,13 +42,13 @@ class ProcessMachineError implements ShouldQueue
$errorCode = $this->payload['error_code'] ?? 'E000'; $errorCode = $this->payload['error_code'] ?? 'E000';
$level = $this->payload['level'] ?? 'error'; $level = $this->payload['level'] ?? 'error';
// 記錄到機台日誌 // 記錄到機台日誌 (使用翻譯友善格式)
ProcessStateLog::dispatch( ProcessStateLog::dispatch(
$machine->id, $machine->id,
$machine->company_id, $machine->company_id,
"[{$errorCode}] {$message}", "Error: :message (:code)",
$level, $level,
$this->payload, ['message' => __($message), 'code' => $errorCode],
'error' 'error'
); );
} }

View File

@ -0,0 +1,73 @@
<?php
namespace App\Jobs\Machine;
use App\Models\Machine\Machine;
use App\Jobs\Machine\ProcessStateLog;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class ProcessStatus implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $serialNo;
protected $payload;
/**
* Create a new job instance.
*/
public function __construct($serialNo, $payload)
{
// 防止 Client ID 帶有 SC_ 前綴或隨機碼 (如 SC_M0408_test)
$cleanSerial = preg_replace('/^SC_/', '', $serialNo);
$this->serialNo = explode('_', $cleanSerial)[0];
$this->payload = $payload;
}
/**
* Execute the job.
*/
public function handle(): void
{
$machine = Machine::withoutGlobalScopes()->where('serial_no', $this->serialNo)->first();
if (!$machine) {
Log::warning("ProcessStatus: Machine not found [{$this->serialNo}]");
return;
}
// Get status from payload, e.g. {"status": "offline"}
$status = $this->payload['status'] ?? null;
if (!$status) {
Log::warning("ProcessStatus: Missing status in payload", ['payload' => $this->payload]);
return;
}
$oldStatus = $machine->status;
$updateData = ['status' => $status];
if ($status === 'online') {
$updateData['last_heartbeat_at'] = now();
}
$machine->update($updateData);
// If status changed, log it
if ($oldStatus !== $status) {
$level = ($status === 'offline') ? 'warning' : 'info';
$message = ($status === 'offline')
? "Connection lost (LWT)"
: "Connection restored";
ProcessStateLog::dispatch($machine->id, $machine->company_id, $message, $level);
Log::info("ProcessStatus: Machine [{$machine->serial_no}] status changed to [{$status}]");
}
}
}

View File

@ -48,9 +48,9 @@ class ProcessTransaction implements ShouldQueue
ProcessStateLog::dispatch( ProcessStateLog::dispatch(
$machine->id, $machine->id,
$machine->company_id, $machine->company_id,
"Transaction processed: " . ($this->payload['transaction_id'] ?? 'N/A'), "Transaction processed: :id",
'info', 'info',
$this->payload, array_merge($this->payload, ['id' => ($this->payload['transaction_id'] ?? 'N/A')]),
'transaction' 'transaction'
); );
} }

View File

@ -45,6 +45,7 @@ class Machine extends Model
'serial_no', 'serial_no',
'model', 'model',
'location', 'location',
'status',
'current_page', 'current_page',
'door_status', 'door_status',
'temperature', 'temperature',
@ -79,20 +80,19 @@ class Machine extends Model
/** /**
* 動態計算機台當前狀態 * 動態計算機台當前狀態
* 1. 離線 (offline):超過 30 秒未收到心跳 * 優先讀取資料庫 status 欄位
* 2. 異常 (error):在線但過去 15 分鐘內有錯誤/警告日誌 * 若在線,則進一步檢查過去 15 分鐘是否為異常
* 3. 在線 (online):正常在線
*/ */
public function getCalculatedStatusAttribute(): string public function getCalculatedStatusAttribute(): string
{ {
// 判定離線 // 判定離線
if (!$this->last_heartbeat_at || $this->last_heartbeat_at->diffInSeconds(now()) >= 30) { if ($this->status === 'offline') {
return 'offline'; return 'offline';
} }
// 判定異常 (檢查過去 15 分鐘內是否有 error 或 warning 日誌) // 判定異常 (檢查過去 15 分鐘內是否有 error 日誌)
$hasRecentErrors = $this->logs() $hasRecentErrors = $this->logs()
->whereIn('level', ['error', 'warning']) ->where('level', 'error')
->where('created_at', '>=', now()->subMinutes(15)) ->where('created_at', '>=', now()->subMinutes(15))
->exists(); ->exists();
@ -104,22 +104,19 @@ class Machine extends Model
} }
/** /**
* Scope: 判定在線 (30 秒內有心跳) * Scope: 判定在線 (讀取 status 欄位)
*/ */
public function scopeOnline($query) public function scopeOnline($query)
{ {
return $query->where('last_heartbeat_at', '>=', now()->subSeconds(30)); return $query->where('status', 'online');
} }
/** /**
* Scope: 判定離線 (超過 30 秒未收到心跳或從未收到心跳) * Scope: 判定離線 (讀取 status 欄位)
*/ */
public function scopeOffline($query) public function scopeOffline($query)
{ {
return $query->where(function ($q) { return $query->where('status', 'offline');
$q->whereNull('last_heartbeat_at')
->orWhere('last_heartbeat_at', '<', now()->subSeconds(30));
});
} }
/** /**

View File

@ -120,8 +120,11 @@ services:
environment: environment:
TZ: 'Asia/Taipei' TZ: 'Asia/Taipei'
EMQX_DASHBOARD__BOOTSTRAP_USER_PASS: 'Star82779061' EMQX_DASHBOARD__BOOTSTRAP_USER_PASS: 'Star82779061'
EMQX_AUTHORIZATION__SOURCES__1__TYPE: 'file'
EMQX_AUTHORIZATION__SOURCES__1__PATH: '/opt/emqx/etc/acl.conf'
volumes: volumes:
- 'sail-emqx:/opt/emqx/data' - 'sail-emqx:/opt/emqx/data'
- './docker/emqx/acl.conf:/opt/emqx/etc/acl.conf'
networks: networks:
- sail - sail
healthcheck: healthcheck:

5
docker/emqx/acl.conf Normal file
View File

@ -0,0 +1,5 @@
{allow, {username, "dashboard"}, subscribe, ["$SYS/#"]}.
{allow, {ipaddr, "127.0.0.1"}, all, ["$SYS/#", "#"]}.
{allow, {username, "star-cloud-gateway"}, subscribe, ["$SYS/#"]}.
{deny, all, subscribe, ["$SYS/#", {eq, "#"}, {eq, "+/#"}]}.
{allow, all}.

View File

@ -17,7 +17,7 @@
"Account updated successfully.": "Account updated successfully.", "Account updated successfully.": "Account updated successfully.",
"Account:": "Account:", "Account:": "Account:",
"accounts": "Account Management", "accounts": "Account Management",
"Accounts \/ Machines": "Accounts \/ Machines", "Accounts / Machines": "Accounts / Machines",
"Action": "Action", "Action": "Action",
"Actions": "Actions", "Actions": "Actions",
"Active": "Active", "Active": "Active",
@ -51,7 +51,7 @@
"Advertisement Management": "Advertisement Management", "Advertisement Management": "Advertisement Management",
"Advertisement updated successfully": "Advertisement updated successfully", "Advertisement updated successfully": "Advertisement updated successfully",
"Advertisement updated successfully.": "Advertisement updated successfully.", "Advertisement updated successfully.": "Advertisement updated successfully.",
"Advertisement Video\/Image": "Advertisement Video\/Image", "Advertisement Video/Image": "Advertisement Video/Image",
"Affiliated Company": "Affiliated Company", "Affiliated Company": "Affiliated Company",
"Affiliated Unit": "Company Name", "Affiliated Unit": "Company Name",
"Affiliation": "Company Name", "Affiliation": "Company Name",
@ -129,7 +129,7 @@
"Back to List": "Back to List", "Back to List": "Back to List",
"Badge Settings": "Badge Settings", "Badge Settings": "Badge Settings",
"Barcode": "Barcode", "Barcode": "Barcode",
"Barcode \/ Material": "Barcode \/ Material", "Barcode / Material": "Barcode / Material",
"Basic Information": "Basic Information", "Basic Information": "Basic Information",
"Basic Settings": "Basic Settings", "Basic Settings": "Basic Settings",
"Basic Specifications": "Basic Specifications", "Basic Specifications": "Basic Specifications",
@ -166,7 +166,7 @@
"Change": "Change", "Change": "Change",
"Change Stock": "Change Stock", "Change Stock": "Change Stock",
"Channel Limits": "Channel Limits", "Channel Limits": "Channel Limits",
"Channel Limits (Track\/Spring)": "Channel Limits (Track\/Spring)", "Channel Limits (Track/Spring)": "Channel Limits (Track/Spring)",
"Channel Limits Configuration": "Channel Limits Configuration", "Channel Limits Configuration": "Channel Limits Configuration",
"ChannelId": "ChannelId", "ChannelId": "ChannelId",
"ChannelSecret": "ChannelSecret", "ChannelSecret": "ChannelSecret",
@ -279,7 +279,7 @@
"Dispensing": "Dispensing", "Dispensing": "Dispensing",
"Duration": "Duration", "Duration": "Duration",
"Duration (Seconds)": "Duration (Seconds)", "Duration (Seconds)": "Duration (Seconds)",
"e.g. 500ml \/ 300g": "e.g. 500ml \/ 300g", "e.g. 500ml / 300g": "e.g. 500ml / 300g",
"e.g. John Doe": "e.g. John Doe", "e.g. John Doe": "e.g. John Doe",
"e.g. johndoe": "e.g. johndoe", "e.g. johndoe": "e.g. johndoe",
"e.g. Taiwan Star": "e.g. Taiwan Star", "e.g. Taiwan Star": "e.g. Taiwan Star",
@ -317,7 +317,7 @@
"Enable Material Code": "Enable Material Code", "Enable Material Code": "Enable Material Code",
"Enable Points": "Enable Points", "Enable Points": "Enable Points",
"Enabled": "Enabled", "Enabled": "Enabled",
"Enabled\/Disabled": "Enabled\/Disabled", "Enabled/Disabled": "Enabled/Disabled",
"End Date": "End Date", "End Date": "End Date",
"Engineer": "Engineer", "Engineer": "Engineer",
"English": "English", "English": "English",
@ -343,7 +343,7 @@
"Execution Time": "Execution Time", "Execution Time": "Execution Time",
"Exp": "Exp", "Exp": "Exp",
"Expired": "Expired", "Expired": "Expired",
"Expired \/ Disabled": "Expired \/ Disabled", "Expired / Disabled": "Expired / Disabled",
"Expiring": "Expiring", "Expiring": "Expiring",
"Expiry": "Expiry", "Expiry": "Expiry",
"Expiry Date": "Expiry Date", "Expiry Date": "Expiry Date",
@ -572,7 +572,7 @@
"Monthly cumulative revenue overview": "Monthly cumulative revenue overview", "Monthly cumulative revenue overview": "Monthly cumulative revenue overview",
"Monthly Transactions": "Monthly Transactions", "Monthly Transactions": "Monthly Transactions",
"Multilingual Names": "Multilingual Names", "Multilingual Names": "Multilingual Names",
"N\/A": "N\/A", "N/A": "N/A",
"Name": "Name", "Name": "Name",
"Name in English": "Name in English", "Name in English": "Name in English",
"Name in Japanese": "Name in Japanese", "Name in Japanese": "Name in Japanese",
@ -715,7 +715,7 @@
"Position": "Position", "Position": "Position",
"Preview": "Preview", "Preview": "Preview",
"Previous": "Previous", "Previous": "Previous",
"Price \/ Member": "Price \/ Member", "Price / Member": "Price / Member",
"Pricing Information": "Pricing Information", "Pricing Information": "Pricing Information",
"Product Count": "Product Count", "Product Count": "Product Count",
"Product created successfully": "Product created successfully", "Product created successfully": "Product created successfully",
@ -827,7 +827,7 @@
"Scale level and access control": "層級與存取控制", "Scale level and access control": "層級與存取控制",
"Scan this code to quickly access the maintenance form for this device.": "Scan this code to quickly access the maintenance form for this device.", "Scan this code to quickly access the maintenance form for this device.": "Scan this code to quickly access the maintenance form for this device.",
"Search accounts...": "Search accounts...", "Search accounts...": "Search accounts...",
"Search by name or S\/N...": "Search by name or S\/N...", "Search by name or S/N...": "Search by name or S/N...",
"Search cargo lane": "Search cargo lane", "Search cargo lane": "Search cargo lane",
"Search Company Title...": "Search Company Title...", "Search Company Title...": "Search Company Title...",
"Search categories...": "Search categories...", "Search categories...": "Search categories...",
@ -904,7 +904,7 @@
"Start Date": "Start Date", "Start Date": "Start Date",
"Statistics": "Statistics", "Statistics": "Statistics",
"Status": "Status", "Status": "Status",
"Status \/ Temp \/ Sub \/ Card \/ Scan": "Status \/ Temp \/ Sub \/ Card \/ Scan", "Status / Temp / Sub / Card / Scan": "Status / Temp / Sub / Card / Scan",
"Stock": "Stock", "Stock": "Stock",
"Stock & Expiry": "Stock & Expiry", "Stock & Expiry": "Stock & Expiry",
"Stock & Expiry Management": "Stock & Expiry Management", "Stock & Expiry Management": "Stock & Expiry Management",
@ -1161,5 +1161,19 @@
"No machines found": "No machines found", "No machines found": "No machines found",
"No products found matching your criteria.": "No products found matching your criteria.", "No products found matching your criteria.": "No products found matching your criteria.",
"No categories found.": "No categories found.", "No categories found.": "No categories found.",
"Track Limit (Track/Spring)": "Track Limit (Track/Spring)" "Track Limit (Track/Spring)": "Track Limit (Track/Spring)",
"Temperature reported: :temp°C": "Temperature reported: :temp°C",
"Temperature updated to :temp°C": "Temperature updated to :temp°C",
"Transaction processed: :id": "Transaction processed: :id",
"Unknown error": "Unknown error",
"Remote dispense successful for slot :slot": "Remote dispense successful for slot :slot",
"Machine not found": "Machine not found",
"Command not found": "Command not found",
"Restock report accepted": "Restock report accepted",
"Result reported": "Result reported",
"Error report accepted": "Error report accepted",
"Unauthorized: Account not found": "Unauthorized: Account not found",
"Unauthorized: Account not authorized for this machine": "Unauthorized: Account not authorized for this machine",
"Slot report synchronized success": "Slot report synchronized success",
"Error: :message (:code)": "Error: :message (:code)"
} }

View File

@ -17,7 +17,7 @@
"Account updated successfully.": "アカウントが正常に更新されました。", "Account updated successfully.": "アカウントが正常に更新されました。",
"Account:": "アカウント:", "Account:": "アカウント:",
"accounts": "アカウント管理", "accounts": "アカウント管理",
"Accounts \/ Machines": "アカウント \/ 機体", "Accounts / Machines": "アカウント / 機体",
"Action": "操作", "Action": "操作",
"Actions": "操作", "Actions": "操作",
"Active": "有効", "Active": "有効",
@ -51,7 +51,7 @@
"Advertisement Management": "広告管理", "Advertisement Management": "広告管理",
"Advertisement updated successfully": "広告の更新に成功しました", "Advertisement updated successfully": "広告の更新に成功しました",
"Advertisement updated successfully.": "広告の更新に成功しました。", "Advertisement updated successfully.": "広告の更新に成功しました。",
"Advertisement Video\/Image": "広告動画\/画像", "Advertisement Video/Image": "広告動画/画像",
"Affiliated Company": "所属会社", "Affiliated Company": "所属会社",
"Affiliated Unit": "所属会社", "Affiliated Unit": "所属会社",
"Affiliation": "所属会社", "Affiliation": "所属会社",
@ -129,7 +129,7 @@
"Back to List": "リストに戻る", "Back to List": "リストに戻る",
"Badge Settings": "バッジ設定", "Badge Settings": "バッジ設定",
"Barcode": "バーコード", "Barcode": "バーコード",
"Barcode \/ Material": "バーコード \/ 素材", "Barcode / Material": "バーコード / 素材",
"Basic Information": "基本情報", "Basic Information": "基本情報",
"Basic Settings": "基本設定", "Basic Settings": "基本設定",
"Basic Specifications": "基本仕様", "Basic Specifications": "基本仕様",
@ -166,7 +166,7 @@
"Change": "変更", "Change": "変更",
"Change Stock": "在庫変更", "Change Stock": "在庫変更",
"Channel Limits": "チャネル制限", "Channel Limits": "チャネル制限",
"Channel Limits (Track\/Spring)": "チャネル上限(トラック\/スプリング)", "Channel Limits (Track/Spring)": "チャネル上限(トラック/スプリング)",
"Channel Limits Configuration": "チャネル制限設定", "Channel Limits Configuration": "チャネル制限設定",
"ChannelId": "チャネルID", "ChannelId": "チャネルID",
"ChannelSecret": "チャネルシークレット", "ChannelSecret": "チャネルシークレット",
@ -279,7 +279,7 @@
"Dispensing": "払い出し中", "Dispensing": "払い出し中",
"Duration": "再生時間", "Duration": "再生時間",
"Duration (Seconds)": "再生時間(秒)", "Duration (Seconds)": "再生時間(秒)",
"e.g. 500ml \/ 300g": "例: 500ml \/ 300g", "e.g. 500ml / 300g": "例: 500ml / 300g",
"e.g. John Doe": "例:山田太郎", "e.g. John Doe": "例:山田太郎",
"e.g. johndoe": "例yamadataro", "e.g. johndoe": "例yamadataro",
"e.g. Taiwan Star": "例Taiwan Star", "e.g. Taiwan Star": "例Taiwan Star",
@ -317,7 +317,7 @@
"Enable Material Code": "品目コードを有効にする", "Enable Material Code": "品目コードを有効にする",
"Enable Points": "ポイントを有効にする", "Enable Points": "ポイントを有効にする",
"Enabled": "有効", "Enabled": "有効",
"Enabled\/Disabled": "有効\/無効", "Enabled/Disabled": "有効/無効",
"End Date": "終了日", "End Date": "終了日",
"Engineer": "エンジニア", "Engineer": "エンジニア",
"English": "英語", "English": "英語",
@ -343,7 +343,7 @@
"Execution Time": "実行時間", "Execution Time": "実行時間",
"Exp": "有効期限", "Exp": "有効期限",
"Expired": "期限切れ", "Expired": "期限切れ",
"Expired \/ Disabled": "期限切れ \/ 無効", "Expired / Disabled": "期限切れ / 無効",
"Expiring": "期限間近", "Expiring": "期限間近",
"Expiry": "期限", "Expiry": "期限",
"Expiry Date": "有効期限", "Expiry Date": "有効期限",
@ -571,7 +571,7 @@
"Monthly cumulative revenue overview": "月間累積収益の概要", "Monthly cumulative revenue overview": "月間累積収益の概要",
"Monthly Transactions": "月間取引数", "Monthly Transactions": "月間取引数",
"Multilingual Names": "多言語名称", "Multilingual Names": "多言語名称",
"N\/A": "N\/A", "N/A": "N/A",
"Name": "名前", "Name": "名前",
"Name in English": "英語名", "Name in English": "英語名",
"Name in Japanese": "日本語名", "Name in Japanese": "日本語名",
@ -632,7 +632,7 @@
"OEE.Hours": "時間", "OEE.Hours": "時間",
"OEE.Orders": "注文数", "OEE.Orders": "注文数",
"OEE.Sales": "売上高", "OEE.Sales": "売上高",
"of": "\/", "of": "/",
"Offline": "オフライン", "Offline": "オフライン",
"Offline Machines": "オフライン機体", "Offline Machines": "オフライン機体",
"Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.": "アカウントを削除すると、そのすべてのリソースとデータが完全に削除されます。削除する前に、保存しておきたいデータをダウンロードしてください。", "Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.": "アカウントを削除すると、そのすべてのリソースとデータが完全に削除されます。削除する前に、保存しておきたいデータをダウンロードしてください。",
@ -714,7 +714,7 @@
"Position": "位置", "Position": "位置",
"Preview": "プレビュー", "Preview": "プレビュー",
"Previous": "前へ", "Previous": "前へ",
"Price \/ Member": "価格 \/ 会員", "Price / Member": "価格 / 会員",
"Pricing Information": "価格情報", "Pricing Information": "価格情報",
"Product Count": "商品数", "Product Count": "商品数",
"Product created successfully": "商品が正常に作成されました", "Product created successfully": "商品が正常に作成されました",
@ -826,7 +826,7 @@
"Scale level and access control": "規模レベルとアクセス制御", "Scale level and access control": "規模レベルとアクセス制御",
"Scan this code to quickly access the maintenance form for this device.": "このコードをスキャンして、端末のメンテナンスフォームに素早くアクセスします。", "Scan this code to quickly access the maintenance form for this device.": "このコードをスキャンして、端末のメンテナンスフォームに素早くアクセスします。",
"Search accounts...": "アカウントを検索...", "Search accounts...": "アカウントを検索...",
"Search by name or S\/N...": "名称または製造番号で検索...", "Search by name or S/N...": "名称または製造番号で検索...",
"Search cargo lane": "貨道を検索", "Search cargo lane": "貨道を検索",
"Search Company Title...": "会社名を検索...", "Search Company Title...": "会社名を検索...",
"Search categories...": "カテゴリーを検索...", "Search categories...": "カテゴリーを検索...",
@ -904,7 +904,7 @@
"Start Date": "開始日", "Start Date": "開始日",
"Statistics": "統計", "Statistics": "統計",
"Status": "ステータス", "Status": "ステータス",
"Status \/ Temp \/ Sub \/ Card \/ Scan": "状態 \/ 温度 \/ 子機 \/ カード \/ スキャン", "Status / Temp / Sub / Card / Scan": "状態 / 温度 / 子機 / カード / スキャン",
"Stock": "在庫", "Stock": "在庫",
"Stock & Expiry": "在庫と消費期限", "Stock & Expiry": "在庫と消費期限",
"Stock & Expiry Management": "在庫・期限管理", "Stock & Expiry Management": "在庫・期限管理",
@ -1117,7 +1117,7 @@
"Page 612": "排出失敗", "Page 612": "排出失敗",
"Door Opened": "機ドア開放", "Door Opened": "機ドア開放",
"Door Closed": "機ドア閉鎖", "Door Closed": "機ドア閉鎖",
"Firmware updated to :version": "ファームウェア更新::version", "Firmware updated to :version": "ファームウェアがバージョン :version に更新されました",
"Model changed to :model": "モデル変更::model", "Model changed to :model": "モデル変更::model",
"User logged in: :name": "ユーザーログイン::name", "User logged in: :name": "ユーザーログイン::name",
"Login failed: :account": "ログイン失敗::account", "Login failed: :account": "ログイン失敗::account",
@ -1160,5 +1160,19 @@
"No machines found": "マシンが見つかりません", "No machines found": "マシンが見つかりません",
"No products found matching your criteria.": "条件に一致する商品が見つかりませんでした。", "No products found matching your criteria.": "条件に一致する商品が見つかりませんでした。",
"No categories found.": "カテゴリーが見つかりませんでした。", "No categories found.": "カテゴリーが見つかりませんでした。",
"Track Limit (Track/Spring)": "在庫上限 (ベルト/スプリング)" "Track Limit (Track/Spring)": "在庫上限 (ベルト/スプリング)",
"Temperature reported: :temp°C": "温度が報告されました::temp°C",
"Temperature updated to :temp°C": "温度が :temp°C に更新されました",
"Transaction processed: :id": "取引が處理されました::id",
"Unknown error": "不明なエラー",
"Remote dispense successful for slot :slot": "スロット :slot の遠隔払い出しに成功しました",
"Machine not found": "機台が見つかりません",
"Command not found": "コマンドが見つかりません",
"Restock report accepted": "補充レポートが受理されました",
"Result reported": "結果が報告されました",
"Error report accepted": "エラーレポートが受理されました",
"Unauthorized: Account not found": "認證失敗:アカウントが見つかりません",
"Unauthorized: Account not authorized for this machine": "認證失敗:このアカウントにはこの機台へのアクセス權限がありません",
"Slot report synchronized success": "スロットレポートの同期に成功しました",
"Error: :message (:code)": "エラー::message (:code)"
} }

View File

@ -17,7 +17,7 @@
"Account updated successfully.": "帳號已成功更新。", "Account updated successfully.": "帳號已成功更新。",
"Account:": "帳號:", "Account:": "帳號:",
"accounts": "帳號管理", "accounts": "帳號管理",
"Accounts \/ Machines": "帳號 \/ 機台", "Accounts / Machines": "帳號 / 機台",
"Action": "操作", "Action": "操作",
"Actions": "操作", "Actions": "操作",
"Active": "使用中", "Active": "使用中",
@ -51,7 +51,7 @@
"Advertisement Management": "廣告管理", "Advertisement Management": "廣告管理",
"Advertisement updated successfully": "廣告更新成功", "Advertisement updated successfully": "廣告更新成功",
"Advertisement updated successfully.": "廣告更新成功。", "Advertisement updated successfully.": "廣告更新成功。",
"Advertisement Video\/Image": "廣告影片\/圖片", "Advertisement Video/Image": "廣告影片/圖片",
"Affiliated Company": "公司名稱", "Affiliated Company": "公司名稱",
"Affiliated Unit": "公司名稱", "Affiliated Unit": "公司名稱",
"Affiliation": "所屬單位", "Affiliation": "所屬單位",
@ -131,7 +131,7 @@
"Back to List": "返回列表", "Back to List": "返回列表",
"Badge Settings": "識別證", "Badge Settings": "識別證",
"Barcode": "條碼", "Barcode": "條碼",
"Barcode \/ Material": "條碼 \/ 物料編碼", "Barcode / Material": "條碼 / 物料編碼",
"Basic Information": "基本資訊", "Basic Information": "基本資訊",
"Basic Settings": "基本設定", "Basic Settings": "基本設定",
"Basic Specifications": "基本規格", "Basic Specifications": "基本規格",
@ -168,7 +168,7 @@
"Change": "更換", "Change": "更換",
"Change Stock": "零錢庫存", "Change Stock": "零錢庫存",
"Channel Limits": "貨道上限", "Channel Limits": "貨道上限",
"Channel Limits (Track\/Spring)": "貨道上限 (履帶\/彈簧)", "Channel Limits (Track/Spring)": "貨道上限 (履帶/彈簧)",
"Channel Limits Configuration": "貨道上限配置", "Channel Limits Configuration": "貨道上限配置",
"ChannelId": "ChannelId", "ChannelId": "ChannelId",
"ChannelSecret": "ChannelSecret", "ChannelSecret": "ChannelSecret",
@ -282,7 +282,7 @@
"Dispensing": "出貨", "Dispensing": "出貨",
"Duration": "時長", "Duration": "時長",
"Duration (Seconds)": "播放秒數", "Duration (Seconds)": "播放秒數",
"e.g. 500ml \/ 300g": "例如500ml \/ 300g", "e.g. 500ml / 300g": "例如500ml / 300g",
"e.g. John Doe": "例如:張曉明", "e.g. John Doe": "例如:張曉明",
"e.g. johndoe": "例如xiaoming", "e.g. johndoe": "例如xiaoming",
"e.g. Taiwan Star": "例如:台灣之星", "e.g. Taiwan Star": "例如:台灣之星",
@ -320,7 +320,7 @@
"Enable Material Code": "啟用物料編號", "Enable Material Code": "啟用物料編號",
"Enable Points": "啟用點數規則", "Enable Points": "啟用點數規則",
"Enabled": "已啟用", "Enabled": "已啟用",
"Enabled\/Disabled": "啟用\/停用", "Enabled/Disabled": "啟用/停用",
"End Date": "截止日", "End Date": "截止日",
"Engineer": "維修人員", "Engineer": "維修人員",
"English": "英文", "English": "英文",
@ -346,7 +346,7 @@
"Execution Time": "執行時間", "Execution Time": "執行時間",
"Exp": "效期", "Exp": "效期",
"Expired": "已過期", "Expired": "已過期",
"Expired \/ Disabled": "已過期 \/ 停用", "Expired / Disabled": "已過期 / 停用",
"Expiring": "效期將屆", "Expiring": "效期將屆",
"Expiry": "效期", "Expiry": "效期",
"Expiry Date": "有效日期", "Expiry Date": "有效日期",
@ -575,7 +575,7 @@
"Monthly cumulative revenue overview": "本月累計營收概況", "Monthly cumulative revenue overview": "本月累計營收概況",
"Monthly Transactions": "本月交易統計", "Monthly Transactions": "本月交易統計",
"Multilingual Names": "多語系名稱", "Multilingual Names": "多語系名稱",
"N\/A": "不適用", "N/A": "不適用",
"Name": "名稱", "Name": "名稱",
"Name in English": "英文名稱", "Name in English": "英文名稱",
"Name in Japanese": "日文名稱", "Name in Japanese": "日文名稱",
@ -719,7 +719,7 @@
"Position": "投放位置", "Position": "投放位置",
"Preview": "預覽", "Preview": "預覽",
"Previous": "上一頁", "Previous": "上一頁",
"Price \/ Member": "售價 \/ 會員價", "Price / Member": "售價 / 會員價",
"Pricing Information": "價格資訊", "Pricing Information": "價格資訊",
"Product Count": "商品數量", "Product Count": "商品數量",
"Product created successfully": "商品已成功建立", "Product created successfully": "商品已成功建立",
@ -831,7 +831,7 @@
"Scale level and access control": "層級與存取控制", "Scale level and access control": "層級與存取控制",
"Scan this code to quickly access the maintenance form for this device.": "掃描此 QR Code 即可快速進入此設備的維修單填寫頁面。", "Scan this code to quickly access the maintenance form for this device.": "掃描此 QR Code 即可快速進入此設備的維修單填寫頁面。",
"Search accounts...": "搜尋帳號...", "Search accounts...": "搜尋帳號...",
"Search by name or S\/N...": "搜尋名稱或序號...", "Search by name or S/N...": "搜尋名稱或序號...",
"Search cargo lane": "搜尋貨道編號或商品名稱", "Search cargo lane": "搜尋貨道編號或商品名稱",
"Search Company Title...": "搜尋公司名稱...", "Search Company Title...": "搜尋公司名稱...",
"Search categories...": "搜尋分類...", "Search categories...": "搜尋分類...",
@ -909,7 +909,7 @@
"Start Date": "起始日", "Start Date": "起始日",
"Statistics": "數據統計", "Statistics": "數據統計",
"Status": "狀態", "Status": "狀態",
"Status \/ Temp \/ Sub \/ Card \/ Scan": "狀態 \/ 溫度 \/ 下位機 \/ 刷卡機 \/ 掃碼機", "Status / Temp / Sub / Card / Scan": "狀態 / 溫度 / 下位機 / 刷卡機 / 掃碼機",
"Stock": "庫存", "Stock": "庫存",
"Stock & Expiry": "庫存與效期", "Stock & Expiry": "庫存與效期",
"Stock & Expiry Management": "庫存與效期管理", "Stock & Expiry Management": "庫存與效期管理",
@ -1127,7 +1127,7 @@
"Page 612": "出貨失敗", "Page 612": "出貨失敗",
"Door Opened": "機門已開啟", "Door Opened": "機門已開啟",
"Door Closed": "機門已關閉", "Door Closed": "機門已關閉",
"Firmware updated to :version": "韌體版本更新:version", "Firmware updated to :version": "韌體版本更新 : :version",
"Model changed to :model": "型號變更::model", "Model changed to :model": "型號變更::model",
"User logged in: :name": "使用者登入::name", "User logged in: :name": "使用者登入::name",
"Login failed: :account": "登入失敗::account", "Login failed: :account": "登入失敗::account",
@ -1169,5 +1169,21 @@
"Failed to load tab content": "載入分頁內容失敗", "Failed to load tab content": "載入分頁內容失敗",
"No machines found": "未找到機台", "No machines found": "未找到機台",
"No products found matching your criteria.": "找不到符合條件的商品。", "No products found matching your criteria.": "找不到符合條件的商品。",
"No categories found.": "找不到分類。" "No categories found.": "找不到分類。",
"Connection lost (LWT)": "連線中斷",
"Connection restored": "連線恢復",
"Temperature reported: :temp°C": "回報溫度 : :temp°C",
"Temperature updated to :temp°C": "溫度已更新為::temp°C",
"Transaction processed: :id": "交易處理完成::id",
"Unknown error": "未知的錯誤",
"Remote dispense successful for slot :slot": "貨道 :slot 遠端出貨成功",
"Machine not found": "找不到機台",
"Command not found": "找不到指令",
"Restock report accepted": "補貨回報已受理",
"Result reported": "結果已回報",
"Error report accepted": "錯誤回報已受理",
"Unauthorized: Account not found": "授權失敗:找不到帳號",
"Unauthorized: Account not authorized for this machine": "授權失敗:此帳號無存取該機台的權限",
"Slot report synchronized success": "貨道狀態同步成功",
"Error: :message (:code)": "錯誤::message (:code)"
} }

View File

@ -23,26 +23,74 @@ func (h *MqttHandler) HandleMessage(client mqtt.Client, msg mqtt.Message) {
log.Printf("Received message on topic: %s", topic) log.Printf("Received message on topic: %s", topic)
// 從 Topic 提取 SerialNo (架構: machine/{serial_no}/{type}) var serialNo, msgType string
parts := strings.Split(topic, "/")
if len(parts) < 3 {
return
}
serialNo := parts[1]
msgType := parts[2]
var jsonPayload interface{} var jsonPayload interface{}
err := json.Unmarshal(payload, &jsonPayload)
if err != nil { // 檢查是否為系統主題 ($SYS)
log.Printf("Invalid JSON payload on topic %s: %v", topic, err) if strings.HasPrefix(topic, "$SYS/") {
return parts := strings.Split(topic, "/")
if len(parts) >= 6 {
serialNo = parts[4]
event := parts[5]
// 解析系統 JSON Payload
var sysData map[string]interface{}
if err := json.Unmarshal(payload, &sysData); err == nil {
if cid, ok := sysData["clientid"].(string); ok {
serialNo = cid
}
}
msgType = "status"
statusValue := "online"
if event == "disconnected" {
statusValue = "offline"
}
jsonPayload = map[string]string{"status": statusValue}
log.Printf("Detected system event: Client [%s] is now [%s]", serialNo, statusValue)
} else {
return
}
} else {
// 一般業務主題 (machine/{serialNo}/{type})
parts := strings.Split(topic, "/")
if len(parts) < 3 {
return
}
serialNo = parts[1]
msgType = parts[2]
err := json.Unmarshal(payload, &jsonPayload)
if err != nil {
// 如果不是有效的 JSON則視為純文字/數值 (Raw Value)
jsonPayload = string(payload)
}
} }
bridgePayload := bridge.BridgePayload{ bridgePayload := bridge.BridgePayload{
Type: msgType, Type: msgType,
SerialNo: serialNo, SerialNo: cleanSerialNo(serialNo),
Payload: jsonPayload, Payload: jsonPayload,
} }
h.pusher.Push(bridgePayload) h.pusher.Push(bridgePayload)
} }
// cleanSerialNo 處理 Client ID 格式,將 SC_M0408_pndk 轉為 M0408
func cleanSerialNo(clientID string) string {
if clientID == "" {
return ""
}
// 1. 移除已知前綴 SC_
cleaned := strings.TrimPrefix(clientID, "SC_")
// 2. 如果中間還有底線,通常代表隨機碼,只取前面部分
// 例如 M0408_test -> M0408
if idx := strings.Index(cleaned, "_"); idx != -1 {
return cleaned[:idx]
}
return cleaned
}

View File

@ -58,10 +58,17 @@ func main() {
// 訂閱所有機台的上行 Topic // 訂閱所有機台的上行 Topic
token := c.Subscribe("machine/+/+", 1, mqttHandler.HandleMessage) token := c.Subscribe("machine/+/+", 1, mqttHandler.HandleMessage)
token.Wait() token.Wait()
if token.Error() != nil {
log.Printf("Failed to subscribe to machine/+/+: %v", token.Error()) // 訂閱 EMQX 5 系統主題 (連線與斷線事件)
sysToken := c.Subscribe("$SYS/brokers/+/clients/+/connected", 1, mqttHandler.HandleMessage)
sysToken.Wait()
sysToken2 := c.Subscribe("$SYS/brokers/+/clients/+/disconnected", 1, mqttHandler.HandleMessage)
sysToken2.Wait()
if token.Error() != nil || sysToken.Error() != nil || sysToken2.Error() != nil {
log.Printf("Failed to subscribe to MQTT topics: %v %v %v", token.Error(), sysToken.Error(), sysToken2.Error())
} else { } else {
log.Println("Successfully subscribed to machine/+/+") log.Println("Successfully subscribed to machine/+/+ and $SYS topics")
} }
}) })

View File

@ -82,24 +82,27 @@
</td> </td>
<td class="px-6 py-6"> <td class="px-6 py-6">
@php @php
$isOnline = $machine->last_heartbeat_at && $machine->last_heartbeat_at->diffInSeconds() < 30; $status = $machine->calculated_status;
@endphp <div class="flex items-center gap-2.5"> @endphp
<div class="flex items-center gap-2.5">
<div class="relative flex h-2.5 w-2.5"> <div class="relative flex h-2.5 w-2.5">
@if($isOnline) @if($status === 'online')
<span <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span> <span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500"></span>
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500"></span> @elseif($status === 'error')
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-amber-500"></span>
@else @else
<span <span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-slate-300 dark:bg-slate-600"></span>
class="relative inline-flex rounded-full h-2.5 w-2.5 bg-slate-300 dark:bg-slate-600"></span>
@endif @endif
</div> </div>
<span <span class="text-xs font-bold uppercase tracking-wider {{
class="text-xs font-bold uppercase tracking-wider {{ $isOnline ? 'text-emerald-500' : 'text-slate-500 dark:text-slate-400' }}"> $status === 'online' ? 'text-emerald-500' : ($status === 'error' ? 'text-amber-500' : 'text-slate-500 dark:text-slate-400')
{{ $isOnline ? __('Online') : __('Offline') }} }}">
{{ __($status === 'online' ? 'Online' : ($status === 'error' ? 'Error' : 'Offline')) }}
</span> </span>
</div> </div>
</td> </td>
<td class="px-6 py-6"> <td class="px-6 py-6">
<div class="text-sm font-bold text-slate-700 dark:text-slate-200"> <div class="text-sm font-bold text-slate-700 dark:text-slate-200">
{{ $machine->card_reader_seconds ?? 0 }}s <span {{ $machine->card_reader_seconds ?? 0 }}s <span