diff --git a/.agents/skills/iot-communication/SKILL.md b/.agents/skills/iot-communication/SKILL.md index 715e846..7170f46 100644 --- a/.agents/skills/iot-communication/SKILL.md +++ b/.agents/skills/iot-communication/SKILL.md @@ -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`)。 --- diff --git a/.agents/skills/mqtt-communication-specs/SKILL.md b/.agents/skills/mqtt-communication-specs/SKILL.md index 9eea487..c4d4b92 100644 --- a/.agents/skills/mqtt-communication-specs/SKILL.md +++ b/.agents/skills/mqtt-communication-specs/SKILL.md @@ -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 規範 diff --git a/app/Console/Commands/ListenMqttQueue.php b/app/Console/Commands/ListenMqttQueue.php index 5f18eb5..821ba1f 100644 --- a/app/Console/Commands/ListenMqttQueue.php +++ b/app/Console/Commands/ListenMqttQueue.php @@ -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); diff --git a/app/Http/Controllers/Api/V1/App/MachineController.php b/app/Http/Controllers/Api/V1/App/MachineController.php index dc08833..14732a1 100644 --- a/app/Http/Controllers/Api/V1/App/MachineController.php +++ b/app/Http/Controllers/Api/V1/App/MachineController.php @@ -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'] ?? '', diff --git a/app/Jobs/Machine/ProcessHeartbeat.php b/app/Jobs/Machine/ProcessHeartbeat.php index dced146..60207ba 100644 --- a/app/Jobs/Machine/ProcessHeartbeat.php +++ b/app/Jobs/Machine/ProcessHeartbeat.php @@ -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); } } diff --git a/app/Jobs/Machine/ProcessMachineError.php b/app/Jobs/Machine/ProcessMachineError.php index 1b13a29..732b47f 100644 --- a/app/Jobs/Machine/ProcessMachineError.php +++ b/app/Jobs/Machine/ProcessMachineError.php @@ -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' ); } diff --git a/app/Jobs/Machine/ProcessStatus.php b/app/Jobs/Machine/ProcessStatus.php new file mode 100644 index 0000000..870a7ed --- /dev/null +++ b/app/Jobs/Machine/ProcessStatus.php @@ -0,0 +1,73 @@ +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}]"); + } + } +} diff --git a/app/Jobs/Machine/ProcessTransaction.php b/app/Jobs/Machine/ProcessTransaction.php index 2870d0d..ff2d401 100644 --- a/app/Jobs/Machine/ProcessTransaction.php +++ b/app/Jobs/Machine/ProcessTransaction.php @@ -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' ); } diff --git a/app/Models/Machine/Machine.php b/app/Models/Machine/Machine.php index 478faa2..0b5b849 100644 --- a/app/Models/Machine/Machine.php +++ b/app/Models/Machine/Machine.php @@ -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'); } /** diff --git a/compose.yaml b/compose.yaml index 01bbcbc..8b1e0e6 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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: diff --git a/docker/emqx/acl.conf b/docker/emqx/acl.conf new file mode 100644 index 0000000..37b33ab --- /dev/null +++ b/docker/emqx/acl.conf @@ -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}. diff --git a/lang/en.json b/lang/en.json index f3b5b29..466d160 100644 --- a/lang/en.json +++ b/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)" } \ No newline at end of file diff --git a/lang/ja.json b/lang/ja.json index 663b582..50cc19f 100644 --- a/lang/ja.json +++ b/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)" } \ No newline at end of file diff --git a/lang/zh_TW.json b/lang/zh_TW.json index a5a0e2a..80984ca 100644 --- a/lang/zh_TW.json +++ b/lang/zh_TW.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": "所屬單位", @@ -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)" } \ No newline at end of file diff --git a/mqtt-gateway/internal/handler/mqtt_handler.go b/mqtt-gateway/internal/handler/mqtt_handler.go index 5c49289..a2cc54a 100644 --- a/mqtt-gateway/internal/handler/mqtt_handler.go +++ b/mqtt-gateway/internal/handler/mqtt_handler.go @@ -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 +} diff --git a/mqtt-gateway/main.go b/mqtt-gateway/main.go index ecc761e..b76e890 100644 --- a/mqtt-gateway/main.go +++ b/mqtt-gateway/main.go @@ -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") } }) diff --git a/resources/views/admin/basic-settings/machines/partials/tab-machines.blade.php b/resources/views/admin/basic-settings/machines/partials/tab-machines.blade.php index 7587b19..29d1f7f 100644 --- a/resources/views/admin/basic-settings/machines/partials/tab-machines.blade.php +++ b/resources/views/admin/basic-settings/machines/partials/tab-machines.blade.php @@ -82,24 +82,27 @@