[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)
### 常見端點處理模式
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`)。
---

View File

@ -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 規範

View File

@ -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);

View File

@ -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'] ?? '',

View File

@ -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);
}
}

View File

@ -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'
);
}

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(
$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'
);
}

View File

@ -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');
}
/**

View File

@ -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
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:": "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)"
}

View File

@ -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)"
}

View File

@ -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)"
}

View File

@ -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
}

View File

@ -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")
}
})

View File

@ -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