[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:
parent
f4f2ca9175
commit
32bc13ac1b
@ -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)
|
||||
|
||||
### 常見端點處理模式
|
||||
1. **B010 (心跳)**:高頻點,走 **MQTT 管線** (`machine/+/heartbeat`)。更新 `last_heard_at` 與感測器快照。
|
||||
2. **B013 (異常)**:事件驅動點,走 **MQTT 管線** (`machine/+/error`)。寫入 `machine_logs` 並觸發告警。
|
||||
3. **B600 (交易)**:高價值點,走 **MQTT 管線** (`machine/+/transaction`)。建立 `Transaction` 紀錄並支援重試。
|
||||
4. **B012 (商品同步)**:大資料量,走 **HTTP 管線**。應確保 Service 層具備緩存 (Cache) 機制。
|
||||
5. **B055 (遠端出貨)**:雲端下發指令,走 **MQTT 下行管線** (`machine/{id}/command`)。
|
||||
1. **$SYS Events**:由 Broker 自動觸發,走 **MQTT 管線**。用於毫秒級更新機台的 `online/offline` 狀態與 `last_heartbeat_at`。
|
||||
2. **B010 (心跳)**:高頻點,走 **MQTT 管線** (`machine/+/heartbeat`)。更新 `last_heartbeat_at` 與感測器快照。
|
||||
3. **B013 (異常)**:事件驅動點,走 **MQTT 管線** (`machine/+/error`)。寫入 `machine_logs` 並觸發告警。
|
||||
4. **B600 (交易)**:高價值點,走 **MQTT 管線** (`machine/+/transaction`)。建立 `Transaction` 紀錄並支援重試。
|
||||
5. **B012 (商品同步)**:大資料量,走 **HTTP 管線**。應確保 Service 層具備緩存 (Cache) 機制。
|
||||
6. **B055 (遠端出貨)**:雲端下發指令,走 **MQTT 下行管線** (`machine/{id}/command`)。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -22,11 +22,13 @@ description: 定義 Star Cloud 與終端機台 (IoT) 之間的 MQTT 全域通訊
|
||||
| WebSocket | 8083 | EMQX 原生 WS 監聽 | NPM 反向代理目標 |
|
||||
| **WSS (公網)** | **443** | App 端透過 NPM 反代連線 | **Android / iOS App** |
|
||||
|
||||
### 1.2 身份認證 (Authentication)
|
||||
### 1.2 身份認證與識別 (Authentication & Identity)
|
||||
機台連線時必須提供以下憑據:
|
||||
- **Username**:機台編號 (`serial_no`),例如 `SN00001`。
|
||||
- **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 認證機制**:
|
||||
- **資料結構**:Redis Hash (`HSET`)。Key 格式為 `{prefix}machine_auth:{username}`,包含 `password` 欄位。
|
||||
@ -36,7 +38,14 @@ description: 定義 Star Cloud 與終端機台 (IoT) 之間的 MQTT 全域通訊
|
||||
> [!CAUTION]
|
||||
> **嚴禁使用 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` (固定值)。
|
||||
- **Password**:與 Redis Hash `machine_auth:star-cloud-gateway` 中的 `password` 欄位對應。
|
||||
- Go Gateway 的認證資料須透過 Laravel 側寫入 Redis。
|
||||
@ -91,7 +100,9 @@ Mqtt3Client client = MqttClient.builder()
|
||||
|
||||
| 主題名稱 (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}/transaction` | 設備 ➜ 雲端 | 1 | 交易完成、出貨結果的回報。 |
|
||||
| `machine/{serial_no}/command` | 雲端 ➜ 設備 | 1 | 雲端下發的即時指令(出貨、更新、重啟)。 |
|
||||
@ -146,6 +157,17 @@ Mqtt3Client client = MqttClient.builder()
|
||||
- `reload_products`: 重新同步商品 (B012)。
|
||||
- `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 規範
|
||||
|
||||
@ -8,6 +8,7 @@ use Illuminate\Support\Facades\Log;
|
||||
use App\Jobs\Machine\ProcessHeartbeat;
|
||||
use App\Jobs\Machine\ProcessMachineError;
|
||||
use App\Jobs\Machine\ProcessTransaction;
|
||||
use App\Jobs\Machine\ProcessStatus;
|
||||
|
||||
class ListenMqttQueue extends Command
|
||||
{
|
||||
@ -37,7 +38,7 @@ class ListenMqttQueue extends Command
|
||||
try {
|
||||
// BLPOP returns [key, value]. Timeout set to 30s to avoid persistent connection read errors.
|
||||
$result = Redis::blpop($queueKey, 30);
|
||||
|
||||
|
||||
if (!$result || !isset($result[1])) {
|
||||
continue;
|
||||
}
|
||||
@ -68,13 +69,28 @@ class ListenMqttQueue extends Command
|
||||
$payload = $data['payload'] ?? [];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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':
|
||||
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;
|
||||
case 'error':
|
||||
ProcessMachineError::dispatch($serialNo, $payload);
|
||||
|
||||
@ -23,73 +23,10 @@ class MachineController extends Controller
|
||||
public function heartbeat(Request $request)
|
||||
{
|
||||
$machine = $request->get('machine');
|
||||
$data = $request->except(['machine', 'key']); // 排除 Middleware 注入的 Model 物件與認證 key
|
||||
$data = $request->except(['machine', 'key']);
|
||||
|
||||
// === 狀態異動觸發 (Redis 快取免查 DB) ===
|
||||
$cacheKey = "machine:{$machine->serial_no}:state";
|
||||
$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);
|
||||
// 異步處理狀態更新 (現在日誌比對統一移至 ProcessHeartbeat)
|
||||
\App\Jobs\Machine\ProcessHeartbeat::dispatch($machine->serial_no, $data);
|
||||
|
||||
// 取出待處理指令
|
||||
$command = \App\Models\Machine\RemoteCommand::where('machine_id', $machine->id)
|
||||
@ -145,7 +82,7 @@ class MachineController extends Controller
|
||||
'status' => $status
|
||||
], 202); // 202 Accepted
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* B055: Get Remote Dispense Queue (POST/GET)
|
||||
* 用於獲取雲端下發的遠端出貨指令詳情。
|
||||
@ -198,13 +135,13 @@ class MachineController extends Controller
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'code' => 404,
|
||||
'message' => 'Command not found'
|
||||
'message' => __('Command not found')
|
||||
], 404);
|
||||
}
|
||||
|
||||
// 更新指令狀態 (0 代表成功)
|
||||
$status = ($type == '0') ? 'success' : 'failed';
|
||||
|
||||
|
||||
$payloadUpdates = [
|
||||
'reported_stock' => $stockReported,
|
||||
'report_type' => $type
|
||||
@ -219,24 +156,24 @@ class MachineController extends Controller
|
||||
if ($slot) {
|
||||
$oldStock = $slot->stock;
|
||||
// 若 APP 回傳的庫存大於 0 則使用回傳值,否則執行扣減
|
||||
$newStock = (int)($stockReported ?? 0);
|
||||
$newStock = (int) ($stockReported ?? 0);
|
||||
if ($newStock <= 0 && $oldStock > 0) {
|
||||
$newStock = $oldStock - 1;
|
||||
}
|
||||
|
||||
|
||||
$finalStock = max(0, $newStock);
|
||||
$slot->update(['stock' => $finalStock]);
|
||||
|
||||
// 紀錄庫存變化供前端顯示
|
||||
$payloadUpdates['old_stock'] = $oldStock;
|
||||
$payloadUpdates['new_stock'] = $finalStock;
|
||||
|
||||
// 記錄狀態變化
|
||||
|
||||
ProcessStateLog::dispatch(
|
||||
$machine->id,
|
||||
$machine->company_id,
|
||||
"Remote dispense successful for slot {$slotNo}",
|
||||
'info'
|
||||
"Remote dispense successful for slot :slot",
|
||||
'info',
|
||||
['slot' => $slotNo]
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -250,7 +187,7 @@ class MachineController extends Controller
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'message' => 'Result reported'
|
||||
'message' => __('Result reported')
|
||||
]);
|
||||
}
|
||||
|
||||
@ -268,7 +205,7 @@ class MachineController extends Controller
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'message' => 'Restock report accepted',
|
||||
'message' => __('Restock report accepted'),
|
||||
'status' => '49'
|
||||
], 202);
|
||||
}
|
||||
@ -282,7 +219,7 @@ class MachineController extends Controller
|
||||
public function getSlots(Request $request)
|
||||
{
|
||||
$machine = $request->get('machine');
|
||||
|
||||
|
||||
// 依貨道編號排序 (Sorted by slot_no as requested)
|
||||
$slots = $machine->slots()->with('product')->orderBy('slot_no')->get();
|
||||
|
||||
@ -292,7 +229,7 @@ class MachineController extends Controller
|
||||
->where('command_type', 'reload_stock')
|
||||
->whereIn('status', ['pending', 'sent'])
|
||||
->update([
|
||||
'status' => 'success',
|
||||
'status' => 'success',
|
||||
'executed_at' => now(),
|
||||
'note' => __('Inventory synced with machine')
|
||||
]);
|
||||
@ -303,12 +240,12 @@ class MachineController extends Controller
|
||||
'data' => $slots->map(function ($slot) {
|
||||
return [
|
||||
'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,
|
||||
'batch_no' => $slot->batch_no,
|
||||
// 保留原始欄位以供除錯或未來擴充
|
||||
'product_id' => $slot->product_id,
|
||||
'capacity' => $slot->max_stock,
|
||||
'capacity' => $slot->max_stock,
|
||||
'status' => $slot->is_active ? '1' : '0',
|
||||
];
|
||||
})
|
||||
@ -460,7 +397,7 @@ class MachineController extends Controller
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'code' => 403,
|
||||
'message' => 'Unauthorized: Account not found',
|
||||
'message' => __('Unauthorized: Account not found'),
|
||||
'status' => ''
|
||||
], 403);
|
||||
}
|
||||
@ -482,7 +419,7 @@ class MachineController extends Controller
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'code' => 403,
|
||||
'message' => 'Unauthorized: Account not authorized for this machine',
|
||||
'message' => __('Unauthorized: Account not authorized for this machine'),
|
||||
'status' => ''
|
||||
], 403);
|
||||
}
|
||||
@ -512,7 +449,7 @@ class MachineController extends Controller
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'message' => 'Slot report synchronized success',
|
||||
'message' => __('Slot report synchronized success'),
|
||||
'status' => '49'
|
||||
]);
|
||||
}
|
||||
@ -572,7 +509,7 @@ class MachineController extends Controller
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'message' => 'Error report accepted',
|
||||
'message' => __('Error report accepted'),
|
||||
], 202); // 202 Accepted
|
||||
}
|
||||
|
||||
@ -594,7 +531,7 @@ class MachineController extends Controller
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'code' => 404,
|
||||
'message' => 'Machine not found'
|
||||
'message' => __('Machine not found')
|
||||
], 404);
|
||||
}
|
||||
|
||||
@ -606,7 +543,7 @@ class MachineController extends Controller
|
||||
$data = [
|
||||
't050v01' => $machine->serial_no,
|
||||
'api_token' => $machine->api_token, // 向 App 核發正式通訊 Token
|
||||
|
||||
|
||||
// 玉山支付
|
||||
't050v41' => $paymentSettings['esun_store_id'] ?? '',
|
||||
't050v42' => $paymentSettings['esun_term_id'] ?? '',
|
||||
@ -622,7 +559,7 @@ class MachineController extends Controller
|
||||
'TP_APP_ID' => $paymentSettings['tp_app_id'] ?? '',
|
||||
'TP_APP_KEY' => $paymentSettings['tp_app_key'] ?? '',
|
||||
'TP_PARTNER_KEY' => $paymentSettings['tp_partner_key'] ?? '',
|
||||
|
||||
|
||||
// 各類行動支付特店 ID
|
||||
'TP_LINE_MERCHANT_ID' => $paymentSettings['tp_line_merchant_id'] ?? '',
|
||||
'TP_PS_MERCHANT_ID' => $paymentSettings['tp_ps_merchant_id'] ?? '',
|
||||
|
||||
@ -20,10 +20,10 @@ class ProcessHeartbeat implements ShouldQueue
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(string $serialNo, array $payload)
|
||||
public function __construct(string $serialNo, $payload)
|
||||
{
|
||||
$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 = [
|
||||
'last_heartbeat_at' => now(),
|
||||
'status' => 'online',
|
||||
];
|
||||
|
||||
// 根據 payload 動態更新欄位
|
||||
if (isset($this->payload['current_page'])) {
|
||||
$updateData['current_page'] = $this->payload['current_page'];
|
||||
}
|
||||
if (isset($this->payload['temperature'])) {
|
||||
$updateData['temperature'] = $this->payload['temperature'];
|
||||
}
|
||||
// === 狀態異動觸發 (Redis 快取,讓 MQTT 也支援日誌) ===
|
||||
$cacheKey = "machine:{$this->serialNo}:state";
|
||||
$oldState = \Illuminate\Support\Facades\Cache::get($cacheKey);
|
||||
$newState = $oldState ?? [];
|
||||
|
||||
// 1. 韌體版本比對 (有改才傳,有改才記)
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -42,13 +42,13 @@ class ProcessMachineError implements ShouldQueue
|
||||
$errorCode = $this->payload['error_code'] ?? 'E000';
|
||||
$level = $this->payload['level'] ?? 'error';
|
||||
|
||||
// 記錄到機台日誌
|
||||
// 記錄到機台日誌 (使用翻譯友善格式)
|
||||
ProcessStateLog::dispatch(
|
||||
$machine->id,
|
||||
$machine->company_id,
|
||||
"[{$errorCode}] {$message}",
|
||||
"Error: :message (:code)",
|
||||
$level,
|
||||
$this->payload,
|
||||
['message' => __($message), 'code' => $errorCode],
|
||||
'error'
|
||||
);
|
||||
}
|
||||
|
||||
73
app/Jobs/Machine/ProcessStatus.php
Normal file
73
app/Jobs/Machine/ProcessStatus.php
Normal 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}]");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -48,9 +48,9 @@ class ProcessTransaction implements ShouldQueue
|
||||
ProcessStateLog::dispatch(
|
||||
$machine->id,
|
||||
$machine->company_id,
|
||||
"Transaction processed: " . ($this->payload['transaction_id'] ?? 'N/A'),
|
||||
"Transaction processed: :id",
|
||||
'info',
|
||||
$this->payload,
|
||||
array_merge($this->payload, ['id' => ($this->payload['transaction_id'] ?? 'N/A')]),
|
||||
'transaction'
|
||||
);
|
||||
}
|
||||
|
||||
@ -45,6 +45,7 @@ class Machine extends Model
|
||||
'serial_no',
|
||||
'model',
|
||||
'location',
|
||||
'status',
|
||||
'current_page',
|
||||
'door_status',
|
||||
'temperature',
|
||||
@ -79,20 +80,19 @@ class Machine extends Model
|
||||
|
||||
/**
|
||||
* 動態計算機台當前狀態
|
||||
* 1. 離線 (offline):超過 30 秒未收到心跳
|
||||
* 2. 異常 (error):在線但過去 15 分鐘內有錯誤/警告日誌
|
||||
* 3. 在線 (online):正常在線
|
||||
* 優先讀取資料庫 status 欄位
|
||||
* 若在線,則進一步檢查過去 15 分鐘是否為異常
|
||||
*/
|
||||
public function getCalculatedStatusAttribute(): string
|
||||
{
|
||||
// 判定離線
|
||||
if (!$this->last_heartbeat_at || $this->last_heartbeat_at->diffInSeconds(now()) >= 30) {
|
||||
if ($this->status === 'offline') {
|
||||
return 'offline';
|
||||
}
|
||||
|
||||
// 判定異常 (檢查過去 15 分鐘內是否有 error 或 warning 日誌)
|
||||
// 判定異常 (檢查過去 15 分鐘內是否有 error 日誌)
|
||||
$hasRecentErrors = $this->logs()
|
||||
->whereIn('level', ['error', 'warning'])
|
||||
->where('level', 'error')
|
||||
->where('created_at', '>=', now()->subMinutes(15))
|
||||
->exists();
|
||||
|
||||
@ -104,22 +104,19 @@ class Machine extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope: 判定在線 (30 秒內有心跳)
|
||||
* Scope: 判定在線 (讀取 status 欄位)
|
||||
*/
|
||||
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)
|
||||
{
|
||||
return $query->where(function ($q) {
|
||||
$q->whereNull('last_heartbeat_at')
|
||||
->orWhere('last_heartbeat_at', '<', now()->subSeconds(30));
|
||||
});
|
||||
return $query->where('status', 'offline');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -120,8 +120,11 @@ services:
|
||||
environment:
|
||||
TZ: 'Asia/Taipei'
|
||||
EMQX_DASHBOARD__BOOTSTRAP_USER_PASS: 'Star82779061'
|
||||
EMQX_AUTHORIZATION__SOURCES__1__TYPE: 'file'
|
||||
EMQX_AUTHORIZATION__SOURCES__1__PATH: '/opt/emqx/etc/acl.conf'
|
||||
volumes:
|
||||
- 'sail-emqx:/opt/emqx/data'
|
||||
- './docker/emqx/acl.conf:/opt/emqx/etc/acl.conf'
|
||||
networks:
|
||||
- sail
|
||||
healthcheck:
|
||||
|
||||
5
docker/emqx/acl.conf
Normal file
5
docker/emqx/acl.conf
Normal 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}.
|
||||
38
lang/en.json
38
lang/en.json
@ -17,7 +17,7 @@
|
||||
"Account updated successfully.": "Account updated successfully.",
|
||||
"Account:": "Account:",
|
||||
"accounts": "Account Management",
|
||||
"Accounts \/ Machines": "Accounts \/ Machines",
|
||||
"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",
|
||||
"Advertisement Video/Image": "Advertisement Video/Image",
|
||||
"Affiliated Company": "Affiliated Company",
|
||||
"Affiliated Unit": "Company Name",
|
||||
"Affiliation": "Company Name",
|
||||
@ -129,7 +129,7 @@
|
||||
"Back to List": "Back to List",
|
||||
"Badge Settings": "Badge Settings",
|
||||
"Barcode": "Barcode",
|
||||
"Barcode \/ Material": "Barcode \/ Material",
|
||||
"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 (Track/Spring)": "Channel Limits (Track/Spring)",
|
||||
"Channel Limits Configuration": "Channel Limits Configuration",
|
||||
"ChannelId": "ChannelId",
|
||||
"ChannelSecret": "ChannelSecret",
|
||||
@ -279,7 +279,7 @@
|
||||
"Dispensing": "Dispensing",
|
||||
"Duration": "Duration",
|
||||
"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. johndoe": "e.g. johndoe",
|
||||
"e.g. Taiwan Star": "e.g. Taiwan Star",
|
||||
@ -317,7 +317,7 @@
|
||||
"Enable Material Code": "Enable Material Code",
|
||||
"Enable Points": "Enable Points",
|
||||
"Enabled": "Enabled",
|
||||
"Enabled\/Disabled": "Enabled\/Disabled",
|
||||
"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",
|
||||
"Expired / Disabled": "Expired / Disabled",
|
||||
"Expiring": "Expiring",
|
||||
"Expiry": "Expiry",
|
||||
"Expiry Date": "Expiry Date",
|
||||
@ -572,7 +572,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",
|
||||
@ -715,7 +715,7 @@
|
||||
"Position": "Position",
|
||||
"Preview": "Preview",
|
||||
"Previous": "Previous",
|
||||
"Price \/ Member": "Price \/ Member",
|
||||
"Price / Member": "Price / Member",
|
||||
"Pricing Information": "Pricing Information",
|
||||
"Product Count": "Product Count",
|
||||
"Product created successfully": "Product created successfully",
|
||||
@ -827,7 +827,7 @@
|
||||
"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 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",
|
||||
"Status / Temp / Sub / Card / Scan": "Status / Temp / Sub / Card / Scan",
|
||||
"Stock": "Stock",
|
||||
"Stock & Expiry": "Stock & Expiry",
|
||||
"Stock & Expiry Management": "Stock & Expiry Management",
|
||||
@ -1161,5 +1161,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)"
|
||||
"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)"
|
||||
}
|
||||
42
lang/ja.json
42
lang/ja.json
@ -17,7 +17,7 @@
|
||||
"Account updated successfully.": "アカウントが正常に更新されました。",
|
||||
"Account:": "アカウント:",
|
||||
"accounts": "アカウント管理",
|
||||
"Accounts \/ Machines": "アカウント \/ 機体",
|
||||
"Accounts / Machines": "アカウント / 機体",
|
||||
"Action": "操作",
|
||||
"Actions": "操作",
|
||||
"Active": "有効",
|
||||
@ -51,7 +51,7 @@
|
||||
"Advertisement Management": "広告管理",
|
||||
"Advertisement updated successfully": "広告の更新に成功しました",
|
||||
"Advertisement updated successfully.": "広告の更新に成功しました。",
|
||||
"Advertisement Video\/Image": "広告動画\/画像",
|
||||
"Advertisement Video/Image": "広告動画/画像",
|
||||
"Affiliated Company": "所属会社",
|
||||
"Affiliated Unit": "所属会社",
|
||||
"Affiliation": "所属会社",
|
||||
@ -129,7 +129,7 @@
|
||||
"Back to List": "リストに戻る",
|
||||
"Badge Settings": "バッジ設定",
|
||||
"Barcode": "バーコード",
|
||||
"Barcode \/ Material": "バーコード \/ 素材",
|
||||
"Barcode / Material": "バーコード / 素材",
|
||||
"Basic Information": "基本情報",
|
||||
"Basic Settings": "基本設定",
|
||||
"Basic Specifications": "基本仕様",
|
||||
@ -166,7 +166,7 @@
|
||||
"Change": "変更",
|
||||
"Change Stock": "在庫変更",
|
||||
"Channel Limits": "チャネル制限",
|
||||
"Channel Limits (Track\/Spring)": "チャネル上限(トラック\/スプリング)",
|
||||
"Channel Limits (Track/Spring)": "チャネル上限(トラック/スプリング)",
|
||||
"Channel Limits Configuration": "チャネル制限設定",
|
||||
"ChannelId": "チャネルID",
|
||||
"ChannelSecret": "チャネルシークレット",
|
||||
@ -279,7 +279,7 @@
|
||||
"Dispensing": "払い出し中",
|
||||
"Duration": "再生時間",
|
||||
"Duration (Seconds)": "再生時間(秒)",
|
||||
"e.g. 500ml \/ 300g": "例: 500ml \/ 300g",
|
||||
"e.g. 500ml / 300g": "例: 500ml / 300g",
|
||||
"e.g. John Doe": "例:山田太郎",
|
||||
"e.g. johndoe": "例:yamadataro",
|
||||
"e.g. Taiwan Star": "例:Taiwan Star",
|
||||
@ -317,7 +317,7 @@
|
||||
"Enable Material Code": "品目コードを有効にする",
|
||||
"Enable Points": "ポイントを有効にする",
|
||||
"Enabled": "有効",
|
||||
"Enabled\/Disabled": "有効\/無効",
|
||||
"Enabled/Disabled": "有効/無効",
|
||||
"End Date": "終了日",
|
||||
"Engineer": "エンジニア",
|
||||
"English": "英語",
|
||||
@ -343,7 +343,7 @@
|
||||
"Execution Time": "実行時間",
|
||||
"Exp": "有効期限",
|
||||
"Expired": "期限切れ",
|
||||
"Expired \/ Disabled": "期限切れ \/ 無効",
|
||||
"Expired / Disabled": "期限切れ / 無効",
|
||||
"Expiring": "期限間近",
|
||||
"Expiry": "期限",
|
||||
"Expiry Date": "有効期限",
|
||||
@ -571,7 +571,7 @@
|
||||
"Monthly cumulative revenue overview": "月間累積収益の概要",
|
||||
"Monthly Transactions": "月間取引数",
|
||||
"Multilingual Names": "多言語名称",
|
||||
"N\/A": "N\/A",
|
||||
"N/A": "N/A",
|
||||
"Name": "名前",
|
||||
"Name in English": "英語名",
|
||||
"Name in Japanese": "日本語名",
|
||||
@ -632,7 +632,7 @@
|
||||
"OEE.Hours": "時間",
|
||||
"OEE.Orders": "注文数",
|
||||
"OEE.Sales": "売上高",
|
||||
"of": "\/",
|
||||
"of": "/",
|
||||
"Offline": "オフライン",
|
||||
"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.": "アカウントを削除すると、そのすべてのリソースとデータが完全に削除されます。削除する前に、保存しておきたいデータをダウンロードしてください。",
|
||||
@ -714,7 +714,7 @@
|
||||
"Position": "位置",
|
||||
"Preview": "プレビュー",
|
||||
"Previous": "前へ",
|
||||
"Price \/ Member": "価格 \/ 会員",
|
||||
"Price / Member": "価格 / 会員",
|
||||
"Pricing Information": "価格情報",
|
||||
"Product Count": "商品数",
|
||||
"Product created successfully": "商品が正常に作成されました",
|
||||
@ -826,7 +826,7 @@
|
||||
"Scale level and access control": "規模レベルとアクセス制御",
|
||||
"Scan this code to quickly access the maintenance form for this device.": "このコードをスキャンして、端末のメンテナンスフォームに素早くアクセスします。",
|
||||
"Search accounts...": "アカウントを検索...",
|
||||
"Search by name or S\/N...": "名称または製造番号で検索...",
|
||||
"Search by name or S/N...": "名称または製造番号で検索...",
|
||||
"Search cargo lane": "貨道を検索",
|
||||
"Search Company Title...": "会社名を検索...",
|
||||
"Search categories...": "カテゴリーを検索...",
|
||||
@ -904,7 +904,7 @@
|
||||
"Start Date": "開始日",
|
||||
"Statistics": "統計",
|
||||
"Status": "ステータス",
|
||||
"Status \/ Temp \/ Sub \/ Card \/ Scan": "状態 \/ 温度 \/ 子機 \/ カード \/ スキャン",
|
||||
"Status / Temp / Sub / Card / Scan": "状態 / 温度 / 子機 / カード / スキャン",
|
||||
"Stock": "在庫",
|
||||
"Stock & Expiry": "在庫と消費期限",
|
||||
"Stock & Expiry Management": "在庫・期限管理",
|
||||
@ -1117,7 +1117,7 @@
|
||||
"Page 612": "排出失敗",
|
||||
"Door Opened": "機ドア開放",
|
||||
"Door Closed": "機ドア閉鎖",
|
||||
"Firmware updated to :version": "ファームウェア更新::version",
|
||||
"Firmware updated to :version": "ファームウェアがバージョン :version に更新されました",
|
||||
"Model changed to :model": "モデル変更::model",
|
||||
"User logged in: :name": "ユーザーログイン::name",
|
||||
"Login failed: :account": "ログイン失敗::account",
|
||||
@ -1160,5 +1160,19 @@
|
||||
"No machines found": "マシンが見つかりません",
|
||||
"No products found matching your criteria.": "条件に一致する商品が見つかりませんでした。",
|
||||
"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)"
|
||||
}
|
||||
@ -17,7 +17,7 @@
|
||||
"Account updated successfully.": "帳號已成功更新。",
|
||||
"Account:": "帳號:",
|
||||
"accounts": "帳號管理",
|
||||
"Accounts \/ Machines": "帳號 \/ 機台",
|
||||
"Accounts / Machines": "帳號 / 機台",
|
||||
"Action": "操作",
|
||||
"Actions": "操作",
|
||||
"Active": "使用中",
|
||||
@ -51,7 +51,7 @@
|
||||
"Advertisement Management": "廣告管理",
|
||||
"Advertisement updated successfully": "廣告更新成功",
|
||||
"Advertisement updated successfully.": "廣告更新成功。",
|
||||
"Advertisement Video\/Image": "廣告影片\/圖片",
|
||||
"Advertisement Video/Image": "廣告影片/圖片",
|
||||
"Affiliated Company": "公司名稱",
|
||||
"Affiliated Unit": "公司名稱",
|
||||
"Affiliation": "所屬單位",
|
||||
@ -131,7 +131,7 @@
|
||||
"Back to List": "返回列表",
|
||||
"Badge Settings": "識別證",
|
||||
"Barcode": "條碼",
|
||||
"Barcode \/ Material": "條碼 \/ 物料編碼",
|
||||
"Barcode / Material": "條碼 / 物料編碼",
|
||||
"Basic Information": "基本資訊",
|
||||
"Basic Settings": "基本設定",
|
||||
"Basic Specifications": "基本規格",
|
||||
@ -168,7 +168,7 @@
|
||||
"Change": "更換",
|
||||
"Change Stock": "零錢庫存",
|
||||
"Channel Limits": "貨道上限",
|
||||
"Channel Limits (Track\/Spring)": "貨道上限 (履帶\/彈簧)",
|
||||
"Channel Limits (Track/Spring)": "貨道上限 (履帶/彈簧)",
|
||||
"Channel Limits Configuration": "貨道上限配置",
|
||||
"ChannelId": "ChannelId",
|
||||
"ChannelSecret": "ChannelSecret",
|
||||
@ -282,7 +282,7 @@
|
||||
"Dispensing": "出貨",
|
||||
"Duration": "時長",
|
||||
"Duration (Seconds)": "播放秒數",
|
||||
"e.g. 500ml \/ 300g": "例如:500ml \/ 300g",
|
||||
"e.g. 500ml / 300g": "例如:500ml / 300g",
|
||||
"e.g. John Doe": "例如:張曉明",
|
||||
"e.g. johndoe": "例如:xiaoming",
|
||||
"e.g. Taiwan Star": "例如:台灣之星",
|
||||
@ -320,7 +320,7 @@
|
||||
"Enable Material Code": "啟用物料編號",
|
||||
"Enable Points": "啟用點數規則",
|
||||
"Enabled": "已啟用",
|
||||
"Enabled\/Disabled": "啟用\/停用",
|
||||
"Enabled/Disabled": "啟用/停用",
|
||||
"End Date": "截止日",
|
||||
"Engineer": "維修人員",
|
||||
"English": "英文",
|
||||
@ -346,7 +346,7 @@
|
||||
"Execution Time": "執行時間",
|
||||
"Exp": "效期",
|
||||
"Expired": "已過期",
|
||||
"Expired \/ Disabled": "已過期 \/ 停用",
|
||||
"Expired / Disabled": "已過期 / 停用",
|
||||
"Expiring": "效期將屆",
|
||||
"Expiry": "效期",
|
||||
"Expiry Date": "有效日期",
|
||||
@ -575,7 +575,7 @@
|
||||
"Monthly cumulative revenue overview": "本月累計營收概況",
|
||||
"Monthly Transactions": "本月交易統計",
|
||||
"Multilingual Names": "多語系名稱",
|
||||
"N\/A": "不適用",
|
||||
"N/A": "不適用",
|
||||
"Name": "名稱",
|
||||
"Name in English": "英文名稱",
|
||||
"Name in Japanese": "日文名稱",
|
||||
@ -719,7 +719,7 @@
|
||||
"Position": "投放位置",
|
||||
"Preview": "預覽",
|
||||
"Previous": "上一頁",
|
||||
"Price \/ Member": "售價 \/ 會員價",
|
||||
"Price / Member": "售價 / 會員價",
|
||||
"Pricing Information": "價格資訊",
|
||||
"Product Count": "商品數量",
|
||||
"Product created successfully": "商品已成功建立",
|
||||
@ -831,7 +831,7 @@
|
||||
"Scale level and access control": "層級與存取控制",
|
||||
"Scan this code to quickly access the maintenance form for this device.": "掃描此 QR Code 即可快速進入此設備的維修單填寫頁面。",
|
||||
"Search accounts...": "搜尋帳號...",
|
||||
"Search by name or S\/N...": "搜尋名稱或序號...",
|
||||
"Search by name or S/N...": "搜尋名稱或序號...",
|
||||
"Search cargo lane": "搜尋貨道編號或商品名稱",
|
||||
"Search Company Title...": "搜尋公司名稱...",
|
||||
"Search categories...": "搜尋分類...",
|
||||
@ -909,7 +909,7 @@
|
||||
"Start Date": "起始日",
|
||||
"Statistics": "數據統計",
|
||||
"Status": "狀態",
|
||||
"Status \/ Temp \/ Sub \/ Card \/ Scan": "狀態 \/ 溫度 \/ 下位機 \/ 刷卡機 \/ 掃碼機",
|
||||
"Status / Temp / Sub / Card / Scan": "狀態 / 溫度 / 下位機 / 刷卡機 / 掃碼機",
|
||||
"Stock": "庫存",
|
||||
"Stock & Expiry": "庫存與效期",
|
||||
"Stock & Expiry Management": "庫存與效期管理",
|
||||
@ -1127,7 +1127,7 @@
|
||||
"Page 612": "出貨失敗",
|
||||
"Door Opened": "機門已開啟",
|
||||
"Door Closed": "機門已關閉",
|
||||
"Firmware updated to :version": "韌體版本更新::version",
|
||||
"Firmware updated to :version": "韌體版本更新 : :version",
|
||||
"Model changed to :model": "型號變更::model",
|
||||
"User logged in: :name": "使用者登入::name",
|
||||
"Login failed: :account": "登入失敗::account",
|
||||
@ -1169,5 +1169,21 @@
|
||||
"Failed to load tab content": "載入分頁內容失敗",
|
||||
"No machines found": "未找到機台",
|
||||
"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)"
|
||||
}
|
||||
@ -23,26 +23,74 @@ func (h *MqttHandler) HandleMessage(client mqtt.Client, msg mqtt.Message) {
|
||||
|
||||
log.Printf("Received message on topic: %s", topic)
|
||||
|
||||
// 從 Topic 提取 SerialNo (架構: machine/{serial_no}/{type})
|
||||
parts := strings.Split(topic, "/")
|
||||
if len(parts) < 3 {
|
||||
return
|
||||
}
|
||||
serialNo := parts[1]
|
||||
msgType := parts[2]
|
||||
|
||||
var serialNo, msgType string
|
||||
var jsonPayload interface{}
|
||||
err := json.Unmarshal(payload, &jsonPayload)
|
||||
if err != nil {
|
||||
log.Printf("Invalid JSON payload on topic %s: %v", topic, err)
|
||||
return
|
||||
|
||||
// 檢查是否為系統主題 ($SYS)
|
||||
if strings.HasPrefix(topic, "$SYS/") {
|
||||
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{
|
||||
Type: msgType,
|
||||
SerialNo: serialNo,
|
||||
SerialNo: cleanSerialNo(serialNo),
|
||||
Payload: jsonPayload,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@ -58,10 +58,17 @@ func main() {
|
||||
// 訂閱所有機台的上行 Topic
|
||||
token := c.Subscribe("machine/+/+", 1, mqttHandler.HandleMessage)
|
||||
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 {
|
||||
log.Println("Successfully subscribed to machine/+/+")
|
||||
log.Println("Successfully subscribed to machine/+/+ and $SYS topics")
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -82,24 +82,27 @@
|
||||
</td>
|
||||
<td class="px-6 py-6">
|
||||
@php
|
||||
$isOnline = $machine->last_heartbeat_at && $machine->last_heartbeat_at->diffInSeconds() < 30;
|
||||
@endphp <div class="flex items-center gap-2.5">
|
||||
$status = $machine->calculated_status;
|
||||
@endphp
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="relative flex h-2.5 w-2.5">
|
||||
@if($isOnline)
|
||||
<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>
|
||||
@if($status === 'online')
|
||||
<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>
|
||||
@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
|
||||
<span
|
||||
class="relative inline-flex rounded-full h-2.5 w-2.5 bg-slate-300 dark:bg-slate-600"></span>
|
||||
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-slate-300 dark:bg-slate-600"></span>
|
||||
@endif
|
||||
</div>
|
||||
<span
|
||||
class="text-xs font-bold uppercase tracking-wider {{ $isOnline ? 'text-emerald-500' : 'text-slate-500 dark:text-slate-400' }}">
|
||||
{{ $isOnline ? __('Online') : __('Offline') }}
|
||||
<span class="text-xs font-bold uppercase tracking-wider {{
|
||||
$status === 'online' ? 'text-emerald-500' : ($status === 'error' ? 'text-amber-500' : 'text-slate-500 dark:text-slate-400')
|
||||
}}">
|
||||
{{ __($status === 'online' ? 'Online' : ($status === 'error' ? 'Error' : 'Offline')) }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-6">
|
||||
<div class="text-sm font-bold text-slate-700 dark:text-slate-200">
|
||||
{{ $machine->card_reader_seconds ?? 0 }}s <span
|
||||
|
||||
Loading…
Reference in New Issue
Block a user