2026年4月6日 星期一

Wokwi ESP32 + Node-Red Dashboard UI Template + AngularJS

 Wokwi ESP32 + Node-Red Dashboard UI Template + AngularJS + RFID  (作業5)




const char* mqtt_server = "broker.emqx.io";

const char* topic_uid    = "alex9ufo/ex7/UID";
const char* topic_ledcon = "alex9ufo/ex7/ledcon";
const char* topic_ledst  = "alex9ufo/ex7/ledst";


rfid-rc522.chip.c 與 rfid-rc522.chip.json 還是需要沿用前面的作業2內容 

WOKWI程式

#include <WiFi.h>

#include <PubSubClient.h>
#include <SPI.h>
#include <MFRC522.h>
#include <LiquidCrystal_I2C.h>

// --- 設定區 ---
const char* ssid = "Wokwi-GUEST";
const char* password = "";
const char* mqtt_server = "broker.emqx.io";

const char* topic_uid    = "alex9ufo/ex7/UID";
const char* topic_ledcon = "alex9ufo/ex7/ledcon";
const char* topic_ledst  = "alex9ufo/ex7/ledst";

#define RST_PIN   4
#define SS_PIN    5
#define LED_PIN   2

MFRC522 mfrc522(SS_PIN, RST_PIN);
WiFiClient espClient;
PubSubClient client(espClient);
LiquidCrystal_I2C lcd(0x27, 16, 2);

// --- 全域變數 ---
unsigned long ledTimerEnd = 0;
bool timerActive = false;
bool flashActive = false;
unsigned long lastFlash = 0;

// --- 函式:同步更新 LCD 與 Serial Monitor ---
void updateDisplay(String msg) {
  // 1. 輸出到 Serial Monitor
  Serial.print("[SYSTEM STATUS]: ");
  Serial.println(msg);

  // 2. 輸出到 LCD 第二行 (先清空該行)
  lcd.setCursor(0, 1);
  lcd.print("                ");
  lcd.setCursor(0, 1);
  lcd.print(msg);
}

void publishStatus(String status) {
  client.publish(topic_ledst, status.c_str());
}

void setup_wifi() {
  delay(10);
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); }
  Serial.println("\nWiFi Connected!");
}

void callback(char* topic, byte* payload, unsigned int length) {
  String message = "";
  for (int i = 0; i < length; i++) { message += (char)payload[i]; }
 
 
  if (message == "on") {
    digitalWrite(LED_PIN, HIGH);
    publishStatus("ON");
    updateDisplay("LED: ON");
    flashActive = false;  // 收到指令,重置計時與閃爍標誌
    timerActive = false;
  }
  else if (message == "off") {
    digitalWrite(LED_PIN, LOW);
    publishStatus("OFF");
    updateDisplay("LED: OFF");
    flashActive = false;  // 收到指令,重置計時與閃爍標誌
    timerActive = false;
  }
  else if (message == "flash") {
    digitalWrite(LED_PIN, LOW);
    lastFlash = millis();
    flashActive = true;
    timerActive = false;
    publishStatus("Flash");
    updateDisplay("LED: FLASHING");
  }
  else if (message == "timer") {
    digitalWrite(LED_PIN, HIGH);
    ledTimerEnd = millis() + 5000;
    timerActive = true;
    flashActive = false;
    publishStatus("Timer");
    updateDisplay("LED: TIMER 5S");
  }
}

void reconnect() {
  while (!client.connected()) {
    if (client.connect("ESP32_Alex_Client")) {
      client.subscribe(topic_ledcon);
      Serial.println("MQTT Connected");
    } else {
      delay(5000);
    }
  }
}

void setup() {
  Serial.begin(115200);
  pinMode(LED_PIN, OUTPUT);

  // --- LCD 初始化加強版 ---
  delay(100);          // 給硬體一點啟動時間

  // 初始化 LCD
  lcd.init();
  lcd.backlight();
  lcd.setCursor(0, 0);
  lcd.print("Alex IoT System");
  updateDisplay("System Booting...");

  // 初始化 RFID
  SPI.begin();
  mfrc522.PCD_Init();

  setup_wifi();
  client.setServer(mqtt_server, 1883);
  client.setCallback(callback);
  updateDisplay("Ready...");
}

void loop() {
  if (!client.connected()) reconnect();
  client.loop();

  // 1. RFID 偵測
  if (mfrc522.PICC_IsNewCardPresent() && mfrc522.PICC_ReadCardSerial()) {
    String uid = "";
    for (byte i = 0; i < mfrc522.uid.size; i++) {
      uid += String(mfrc522.uid.uidByte[i] < 0x10 ? "0" : "");
      uid += String(mfrc522.uid.uidByte[i], HEX);
    }
    uid.toUpperCase();
   
    // 發送 MQTT 並更新顯示
    client.publish(topic_uid, uid.c_str());
    updateDisplay("UID: " + uid);
   
    mfrc522.PICC_HaltA();
    mfrc522.PCD_StopCrypto1();
    delay(1000);
  }

  // 2. Timer 處理
  if (timerActive) {
    if (millis() >= ledTimerEnd) {
      digitalWrite(LED_PIN, LOW);
      timerActive = false;
      publishStatus("OFF");
      updateDisplay("LED: TIME UP");
    }
  }

  // 3. Flash 處理
  if (flashActive) {
    if (millis() - lastFlash >= 250) {
      digitalWrite(LED_PIN, !digitalRead(LED_PIN));
      lastFlash = millis();
    }
  }
}

這段程式碼實現了一個完整的 ESP32 IoT 應用,結合了 RFID 讀取MQTT 遠端控制I2C LCD 顯示以及非阻塞式(Non-blocking)的 LED 特效

以下是逐行的詳細說明:

1. 引用函式庫與定義

  • 第 1-5 行:引入必要的函式庫。WiFi 處理網路,PubSubClient 處理 MQTT 通訊,SPIMFRC522 用於 RFID 模組,LiquidCrystal_I2C 用於螢幕顯示。

  • 第 8-15 行:設定 WiFi 資訊、MQTT Broker 位址以及三個通訊主題(UID、控制指令、狀態回報)。

  • 第 17-19 行:定義硬體接腳。RST 為 4,SS (SDA) 為 5,LED 接在 GPIO 2。

2. 物件初始化與變數

  • 第 21-24 行:建立 RFID、WiFi、MQTT 以及 LCD 的物件實例。

  • 第 27-30 行:全域變數。ledTimerEnd 儲存定時結束的時間點;timerActiveflashActive 是標記目前是否處於定時或閃爍模式的「旗標」;lastFlash 用於計算閃爍間隔。

3. 功能函式 (Utilities)

  • updateDisplay (第 33-43 行):自定義函式。

    • 將訊息同步印到 Serial Monitor(序列埠監控視窗)。

    • 清空 LCD 第二行後印出新訊息,方便使用者從硬體直接看到狀態。

  • publishStatus (第 45-47 行):簡化程式,將 LED 的當前狀態(如 ON/OFF)發送到 MQTT ledst 主題。

  • setup_wifi (第 49-53 行):啟動 WiFi 連線,並在連線成功前不斷印出 . 顯示進度。

4. MQTT 指令回呼 (Callback)

  • callback (第 55-89 行):這是最重要的部分。當 Node-RED 發送訊息時,此函式會被觸發:

    • ON/OFF:直接切換 LED 電位,並將兩個模式旗標設為 false(停止閃爍或定時)。

    • Flash:將 flashActive 設為 true,並重置 lastFlash 開始計時。

    • Timer:將 LED 點亮,並設定 ledTimerEnd 為「目前時間 + 5000 毫秒」,開啟 timerActive

5. 連線與初始化

  • reconnect (第 91-100 行):MQTT 斷線重連機制。連線成功後務必執行 client.subscribe 重新訂閱指令主題。

  • setup (第 102-124 行):硬體初始化。

    • 啟動序列埠。

    • 初始化 LCD 並開啟背光。

    • 初始化 SPI 與 RFID 晶片。

    • 連線 WiFi 並設定 MQTT 伺服器與回呼函式。

6. 主迴圈 (Loop)

  • 第 127-128 行:檢查 MQTT 連線,並保持背景通訊(client.loop())。

  • RFID 偵測 (第 131-146 行)

    • 檢查是否有卡片靠近。

    • 將 UID(卡片 ID)轉換成十六進位字串。

    • 透過 MQTT 發布 UID,並顯示在 LCD。

    • 執行 PICC_HaltA() 停止重複讀取同一張卡。

  • Timer 處理 (第 149-156 行)

    • 若定時模式開啟,檢查 millis() 是否超過了預設的結束時間。

    • 時間一到,自動熄滅 LED,發布 OFF 狀態。

  • Flash 處理 (第 159-165 行)

    • 若閃爍模式開啟,利用 millis() 檢查是否過了 250 毫秒。

    • 若是,則反轉(Invert)LED 的目前狀態,達到閃爍效果且不會卡住程式。


邏輯優點總結

這段程式碼最大的優點是使用了 millis() 而非 delay()。這意味著當 LED 在「閃爍」或是「5秒定時」的過程中,ESP32 仍然可以同時偵測 RFID 卡片以及接收新的 MQTT 指令,不會產生遲鈍感。

Node-Red程式

[{"id":"35c33e4f640a7d69","type":"ui_template","z":"25666aa01ece9150","group":"0317d60d4d4e23f4","name":"","order":0,"width":0,"height":0,"format":"<div ng-init=\"lastUID='等待感應...'\">\n    <div\n        style=\"background: #1e1e1e; padding: 30px; border-radius: 15px; text-align: center; color: white; box-shadow: 0 8px 16px rgba(0,0,0,0.4); border: 2px solid #3498db;\">\n\n        <div\n            style=\"font-size: 0.9em; color: #3498db; margin-bottom: 10px; font-weight: bold; text-transform: uppercase; letter-spacing: 2px;\">\n            <i class=\"fa fa-id-card\"></i> RFID Reader Status\n        </div>\n\n        <div style=\"background: rgba(255,255,255,0.05); padding: 20px; border-radius: 10px; border: 1px solid #444;\">\n            <div style=\"color: #aaa; font-size: 0.8em; margin-bottom: 5px;\">CURRENT SCANNED UID:</div>\n            <div\n                style=\"font-size: 2em; font-family: 'Courier New', Courier, monospace; font-weight: bold; color: #ffffff; text-shadow: 0 0 10px #3498db;\">\n                {{lastUID}}\n            </div>\n        </div>\n\n        <div style=\"margin-top: 15px; font-size: 0.75em; color: #666;\">\n            系統時間: {{lastUpdateTime || '---'}}\n        </div>\n    </div>\n</div>\n\n<script>\n    (function(scope) {\n    scope.$watch('msg', function(msg) {\n        if (!msg) return;\n\n        // 監聽來自 MQTT 的新 UID 掃描 (alex9ufo/ex7/UID)\n        if (msg.topic === \"alex9ufo/ex7/UID\") {\n            scope.lastUID = msg.payload;\n            scope.lastUpdateTime = new Date().toLocaleTimeString();\n        }\n    });\n})(scope);\n</script>","storeOutMessages":true,"fwdInMessages":true,"resendOnRefresh":true,"templateScope":"local","className":"","x":380,"y":200,"wires":[[]]},{"id":"0d74265fb42ae932","type":"function","z":"25666aa01ece9150","name":"function ","func":"let now = new Date();\nlet d = now.toLocaleDateString('zh-TW');\nlet t = now.toLocaleTimeString('zh-TW', { hour12: false });\nvar st= msg.payload;\n\nmsg.topic = \"INSERT INTO data_logs (status, date, time) VALUES ($st,  $d ,  $t)\";\nmsg.payload = [st, d, t];\nreturn msg;\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":360,"y":60,"wires":[["284753733ebd1a98","3f81d528c3855f7d"]]},{"id":"68afea41a89aede0","type":"mqtt in","z":"25666aa01ece9150","name":"","topic":"alex9ufo/ex7/UID","qos":"1","datatype":"auto-detect","broker":"b283845a8722b420","nl":false,"rap":false,"rh":0,"inputs":0,"x":140,"y":200,"wires":[["35c33e4f640a7d69"]]},{"id":"284753733ebd1a98","type":"sqlite","z":"25666aa01ece9150","mydb":"0c6e9b903744a9ef","sqlquery":"msg.topic","sql":"","name":"2026-ex7","x":560,"y":80,"wires":[["3e4d572583ed3831"]]},{"id":"3e4d572583ed3831","type":"debug","z":"25666aa01ece9150","name":"debug 378","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"false","statusVal":"","statusType":"auto","x":730,"y":80,"wires":[]},{"id":"3f81d528c3855f7d","type":"debug","z":"25666aa01ece9150","name":"debug 379","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"topic","targetType":"msg","statusVal":"","statusType":"auto","x":590,"y":40,"wires":[]},{"id":"0a5d2681d434f50b","type":"mqtt out","z":"25666aa01ece9150","name":"","topic":"alex9ufo/ex7/ledcon","qos":"1","retain":"","respTopic":"","contentType":"","userProps":"","correl":"","expiry":"","broker":"b283845a8722b420","x":640,"y":160,"wires":[]},{"id":"90f1d94821a45e30","type":"ui_template","z":"25666aa01ece9150","group":"93ff3ebbc526669a","name":"","order":0,"width":0,"height":0,"format":"<div style=\"padding: 10px; background: #f4f4f4; border-radius: 10px; border: 1px solid #ccc;\">\n    <h3 style=\"margin-top: 0; color: #333; border-bottom: 2px solid #3498db; padding-bottom: 5px;\">\n        <i class=\"fa fa-database\"></i> 2026-ex7.db 系統維護\n    </h3>\n\n    <div style=\"display: flex; flex-direction: column; gap: 15px; margin-top: 15px;\">\n\n        <div style=\"background: white; padding: 10px; border-radius: 5px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);\">\n            <p style=\"margin: 0 0 10px 0; font-size: 0.9em; color: #666;\">若資料庫尚未初始化或換新檔,請執行此項:</p>\n            <button ng-click=\"dbAction('CREATE')\"\n                    style=\"width: 100%; background: #34495e; color: white; border: none; padding: 12px; border-radius: 4px; font-weight: bold; cursor: pointer;\">\n                初始化 / 建立資料表\n            </button>\n        </div>\n\n        <div\n            style=\"background: #fff5f5; padding: 10px; border-radius: 5px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); border: 1px solid #ffcccc;\">\n            <p style=\"margin: 0 0 10px 0; font-size: 0.9em; color: #c0392b; font-weight: bold;\">危險區域:</p>\n            <button ng-click=\"dbAction('DELETE')\"\n                    style=\"width: 100%; background: #e74c3c; color: white; border: none; padding: 12px; border-radius: 4px; font-weight: bold; cursor: pointer;\">\n                清空所有歷史紀錄\n            </button>\n        </div>\n\n    </div>\n\n    <div ng-if=\"statusMsg\"\n        style=\"margin-top: 15px; padding: 10px; background: #d4edda; color: #155724; border-radius: 4px; font-size: 0.9em; text-align: center;\">\n        {{statusMsg}}\n    </div>\n</div>\n\n<script>\n    (function(scope) {\n    scope.statusMsg = \"\";\n\n    scope.dbAction = function(type) {\n        if (type === 'CREATE') {\n            const sql = \"CREATE TABLE IF NOT EXISTS data_logs (id INTEGER PRIMARY KEY AUTOINCREMENT, status TEXT, date TEXT, time TEXT)\";\n            scope.send({topic: \"DB_ADMIN\", payload: sql});\n            scope.statusMsg = \"已發送指令:建立資料表\";\n        } \n        else if (type === 'DELETE') {\n            if (confirm(\"警告!這將永久刪除所有歷史紀錄,確定要執行嗎?\")) {\n                // 發送刪除指令與壓縮資料庫指令\n                scope.send({topic: \"DB_ADMIN\", payload: \"DELETE FROM data_logs\"});\n                // 這裡延遲發送 VACUUM 以重整空間\n                setTimeout(function() {\n                    scope.send({topic: \"DB_ADMIN\", payload: \"VACUUM\"});\n                }, 500);\n                scope.statusMsg = \"已發送指令:清空資料庫\";\n            }\n        }\n        \n        // 3秒後自動消失狀態文字\n        setTimeout(function() {\n            scope.statusMsg = \"\";\n            scope.$apply();\n        }, 3000);\n    };\n})(scope);\n</script>","storeOutMessages":true,"fwdInMessages":true,"resendOnRefresh":true,"templateScope":"local","className":"","x":120,"y":280,"wires":[["126e9374abfdfcf8"]]},{"id":"847c85ee2909ffce","type":"sqlite","z":"25666aa01ece9150","mydb":"0c6e9b903744a9ef","sqlquery":"msg.topic","sql":"","name":"2026-ex7","x":540,"y":280,"wires":[["e3e5880d94dc1292"]]},{"id":"7d0e0155adb1a9e3","type":"sqlite","z":"25666aa01ece9150","mydb":"0c6e9b903744a9ef","sqlquery":"msg.topic","sql":"","name":"2026-ex7","x":340,"y":380,"wires":[["6d8bf3cf2217dddf"]]},{"id":"6d8bf3cf2217dddf","type":"ui_template","z":"25666aa01ece9150","group":"b8bea6bc6ecd9b16","name":"","order":0,"width":0,"height":0,"format":"<div ng-init=\"history=[]\">\n    <div\n        style=\"display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; border-left: 5px solid #3498db; padding-left: 10px;\">\n        <h3 style=\"margin: 0; font-size: 1.1em; color: #333;\">2026ex7.db 歷史紀錄 (最新 25 筆)</h3>\n        <button ng-click=\"send({topic: 'REFRESH_DB', payload: 'SELECT'})\"\n                style=\"background: #3498db; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer; font-size: 0.8em;\">\n            <i class=\"fa fa-refresh\"></i> 手動刷新\n        </button>\n    </div>\n\n    <div\n        style=\"max-height: 500px; overflow-y: auto; border: 1px solid #ddd; border-radius: 5px; box-shadow: 0 2px 4px rgba(0,0,0,0.05);\">\n        <table\n            style=\"width: 100%; border-collapse: collapse; background: white; font-size: 0.9em; font-family: sans-serif;\">\n            <thead style=\"position: sticky; top: 0; background: #34495e; color: white; z-index: 1;\">\n                <tr>\n                    <th style=\"padding: 10px; text-align: center; border-right: 1px solid #455a64;\">ID</th>\n                    <th style=\"padding: 10px; text-align: left; border-right: 1px solid #455a64;\">事件 / UID</th>\n                    <th style=\"padding: 10px; text-align: center; border-right: 1px solid #455a64;\">日期</th>\n                    <th style=\"padding: 10px; text-align: center;\">時間</th>\n                </tr>\n            </thead>\n            <tbody>\n                <tr ng-repeat=\"row in history\"\n                    ng-style=\"{'background-color': ($index % 2 === 0 ? '#ffffff' : '#f9f9f9')}\"\n                    style=\"border-bottom: 1px solid #eee;\">\n                    <td style=\"padding: 10px; text-align: center; color: #666;\">{{row.id}}</td>\n                    <td style=\"padding: 10px;\">\n                        <span ng-if=\"row.status.indexOf('UID:') !== -1\" style=\"color: #2980b9; font-weight: bold;\">\n                            <i class=\"fa fa-id-card\"></i> {{row.status}}\n                        </span>\n                        <span ng-if=\"row.status.indexOf('UID:') === -1\"\n                              ng-style=\"{'color': (row.status === 'OFF' ? '#e74c3c' : '#27ae60')}\"\n                              style=\"font-weight: bold;\">\n                            <i class=\"fa fa-cog\"></i> {{row.status}}\n                        </span>\n                    </td>\n                    <td style=\"padding: 10px; text-align: center; color: #444;\">{{row.date}}</td>\n                    <td style=\"padding: 10px; text-align: center; color: #444;\">{{row.time}}</td>\n                </tr>\n                <tr ng-if=\"!history || history.length === 0\">\n                    <td colspan=\"4\" style=\"padding: 20px; text-align: center; color: #999;\">暫無紀錄 (或正在載入資料...)</td>\n                </tr>\n            </tbody>\n        </table>\n    </div>\n    <div style=\"margin-top: 5px; font-size: 0.75em; color: #999; text-align: right;\">\n        最後刷新: {{lastUpdate}}\n    </div>\n</div>\n\n<script>\n    (function(scope) {\n    scope.$watch('msg', function(msg) {\n        if (!msg) return;\n\n        // 重要:SQLite 節點查詢後的結果通常直接放在 msg.payload 中\n        if (Array.isArray(msg.payload)) {\n            scope.history = msg.payload;\n            scope.lastUpdate = new Date().toLocaleTimeString();\n            \n            // 強制 AngularJS 檢查變數變更,確保表格刷新\n            // scope.$apply(); // 在某些 Node-RED 版本中需要,若報錯可註解掉\n        }\n    });\n})(scope);\n</script>","storeOutMessages":true,"fwdInMessages":true,"resendOnRefresh":true,"templateScope":"local","className":"","x":500,"y":380,"wires":[["d4ec91cbab80f917"]]},{"id":"d4ec91cbab80f917","type":"debug","z":"25666aa01ece9150","name":"debug 380","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"false","statusVal":"","statusType":"auto","x":710,"y":380,"wires":[]},{"id":"e3e5880d94dc1292","type":"debug","z":"25666aa01ece9150","name":"debug 381","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"false","statusVal":"","statusType":"auto","x":730,"y":280,"wires":[]},{"id":"2ac0e4980e3f4525","type":"debug","z":"25666aa01ece9150","name":"debug 382","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"false","statusVal":"","statusType":"auto","x":550,"y":320,"wires":[]},{"id":"126e9374abfdfcf8","type":"function","z":"25666aa01ece9150","name":"function ","func":"// 僅處理來自 UI 維護按鈕的 DB_ADMIN 指令\nif (msg.topic === \"DB_ADMIN\") {\n    msg.topic = msg.payload; \n    msg.payload = []; \n    return msg;\n}\nreturn null; // 攔截其他所有訊息,不讓它們從這裡進入 DB","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":360,"y":280,"wires":[["2ac0e4980e3f4525","847c85ee2909ffce"]]},{"id":"9ac925e7202aca49","type":"mqtt in","z":"25666aa01ece9150","name":"","topic":"alex9ufo/ex7/ledst","qos":"1","datatype":"auto-detect","broker":"b283845a8722b420","nl":false,"rap":false,"rh":0,"inputs":0,"x":150,"y":60,"wires":[["0d74265fb42ae932"]]},{"id":"852c85434821d2b0","type":"comment","z":"25666aa01ece9150","name":"新增 recoder --2026-ex7.db","info":"CREATE TABLE IF NOT EXISTS data_logs (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    status TEXT,\n    date TEXT,\n    time TEXT\n);","x":170,"y":20,"wires":[]},{"id":"20fb8eea5d166935","type":"mqtt in","z":"25666aa01ece9150","name":"","topic":"alex9ufo/ex7/UID","qos":"1","datatype":"auto-detect","broker":"b283845a8722b420","nl":false,"rap":false,"rh":0,"inputs":0,"x":140,"y":100,"wires":[["0d74265fb42ae932"]]},{"id":"72a50948abab5c44","type":"ui_template","z":"25666aa01ece9150","group":"9cc17ea5438167fa","name":"","order":0,"width":0,"height":0,"format":"<div ng-init=\"ledStatus='OFF'\">\n    <div\n        style=\"background: #222; padding: 25px; border-radius: 15px; text-align: center; color: white; margin-bottom: 20px; box-shadow: 0 4px 10px rgba(0,0,0,0.3);\">\n        <div style=\"font-size: 1.3em; margin-bottom: 15px; letter-spacing: 1px;\">\n            LED 模式: <b ng-style=\"{color: (ledStatus === 'OFF' ? '#ff4d4d' : '#00ff00')}\">{{ledStatus}}</b>\n        </div>\n\n        <div ng-style=\"{'background-color': (ledStatus === 'OFF' ? '#ff4d4d' : '#00ff00'), 'box-shadow': '0 0 20px ' + (ledStatus === 'OFF' ? '#ff4d4d' : '#00ff00')}\"\n            style=\"width: 60px; height: 60px; border-radius: 50%; margin: 0 auto; transition: 0.4s ease-in-out; border: 3px solid #444;\">\n        </div>\n    </div>\n\n    <div style=\"display: grid; grid-template-columns: 1fr 1fr; gap: 12px;\">\n        <button ng-click=\"send({topic: 'alex9ufo/ex7/ledcon', payload: 'on'})\"\n                style=\"background: #2ecc71; border: none; color: white; padding: 20px; border-radius: 8px; cursor: pointer; font-weight: bold; font-size: 1.1em;\">\n            ON\n        </button>\n\n        <button ng-click=\"send({topic: 'alex9ufo/ex7/ledcon', payload: 'off'})\"\n                style=\"background: #e74c3c; border: none; color: white; padding: 20px; border-radius: 8px; cursor: pointer; font-weight: bold; font-size: 1.1em;\">\n            OFF\n        </button>\n\n        <button ng-click=\"send({topic: 'alex9ufo/ex7/ledcon', payload: 'flash'})\"\n                style=\"background: #f1c40f; border: none; color: black; padding: 20px; border-radius: 8px; cursor: pointer; font-weight: bold; font-size: 1.1em;\">\n            FLASH\n        </button>\n\n        <button ng-click=\"send({topic: 'alex9ufo/ex7/ledcon', payload: 'timer'})\"\n                style=\"background: #3498db; border: none; color: white; padding: 20px; border-radius: 8px; cursor: pointer; font-weight: bold; font-size: 1.1em;\">\n            TIMER (5s)\n        </button>\n    </div>\n</div>\n\n<script>\n    (function(scope) {\n    scope.$watch('msg', function(msg) {\n        if (!msg) return;\n\n        // 監聽來自 MQTT 的 LED 狀態更新 (例如: ON, OFF, Flash, Timer)\n        // 來源主題需為: alex9ufo/ex7/ledst\n        if (msg.topic === \"alex9ufo/ex7/ledst\") {\n            scope.ledStatus = msg.payload;\n        }\n    });\n})(scope);\n</script>","storeOutMessages":true,"fwdInMessages":true,"resendOnRefresh":true,"templateScope":"local","className":"","x":380,"y":160,"wires":[["0a5d2681d434f50b"]]},{"id":"4c0408017879ba16","type":"mqtt in","z":"25666aa01ece9150","name":"","topic":"alex9ufo/ex7/ledst","qos":"1","datatype":"auto-detect","broker":"b283845a8722b420","nl":false,"rap":false,"rh":0,"inputs":0,"x":150,"y":160,"wires":[["72a50948abab5c44"]]},{"id":"48edbb37c788666f","type":"inject","z":"25666aa01ece9150","name":"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"2","crontab":"","once":true,"onceDelay":0.1,"topic":"SELECT * FROM data_logs ORDER BY id DESC LIMIT 25","payload":"","payloadType":"date","x":140,"y":380,"wires":[["7d0e0155adb1a9e3"]]},{"id":"0317d60d4d4e23f4","type":"ui_group","name":"UID","tab":"0b9280dac9c490f1","order":4,"disp":true,"width":"6","collapse":false,"className":""},{"id":"b283845a8722b420","type":"mqtt-broker","name":"","broker":"broker.emqx.io","port":"1883","clientid":"","autoConnect":true,"usetls":false,"protocolVersion":"4","keepalive":"60","cleansession":true,"autoUnsubscribe":true,"birthTopic":"","birthQos":"0","birthRetain":"false","birthPayload":"","birthMsg":{},"closeTopic":"","closeQos":"0","closeRetain":"false","closePayload":"","closeMsg":{},"willTopic":"","willQos":"0","willRetain":"false","willPayload":"","willMsg":{},"userProps":"","sessionExpiry":""},{"id":"0c6e9b903744a9ef","type":"sqlitedb","db":"2026ex7.db","mode":"RWC"},{"id":"93ff3ebbc526669a","type":"ui_group","name":"database command","tab":"0b9280dac9c490f1","order":2,"disp":true,"width":"6","collapse":false,"className":""},{"id":"b8bea6bc6ecd9b16","type":"ui_group","name":"Show Data","tab":"0b9280dac9c490f1","order":3,"disp":true,"width":"6","collapse":false,"className":""},{"id":"9cc17ea5438167fa","type":"ui_group","name":"control","tab":"0b9280dac9c490f1","order":1,"disp":true,"width":"6","collapse":false,"className":""},{"id":"0b9280dac9c490f1","type":"ui_tab","name":"2026-ex7","icon":"dashboard","disabled":false,"hidden":false}]


這份 Node-RED 程式碼構建了一個完整的物聯網(IoT)儀表板,專門用於監控 RFID 掃描、控制 ESP32 的 LED 模式,並將所有事件記錄到 SQLite 資料庫 中。

以下是程式碼各個區塊的詳細說明:

1. 訊息接收與資料記錄 (上層流程)

這部分負責處理從 ESP32 傳回的資訊(UID 與 LED 狀態)。

  • MQTT In (ledst & UID): 分別訂閱兩個主題。當 ESP32 掃描到卡片或 LED 狀態改變時,訊息會進入此處。

  • Function (0d74265fb42ae932):

    • 負責將目前的日期(Date)與時間(Time)封裝進訊息。

    • 產生 SQL 語法:INSERT INTO data_logs ...

  • SQLite (2026-ex7): 將訊息存入名為 2026ex7.db 的資料庫檔案中。

  • Debug (378, 379): 在 Node-RED 側邊欄顯示訊息,幫助你確認資料是否正確存入。

2. LED 即時監控與控制 (中層流程)

這部分是與使用者互動的人機介面(Dashboard)。

  • MQTT In (ledst): 接收 ESP32 目前的燈號狀態(ON, OFF, Flash...)。

  • UI Template (control):

    • 顯示區域:上方有一個圓形指示燈,透過顏色變化同步顯示 ESP32 的 LED 狀態。

    • 控制按鈕:提供四個按鈕(ON, OFF, FLASH, TIMER),點擊後會透過 MQTT 發送指令給 ESP32。

  • MQTT Out (ledcon): 將按鈕指令傳送回 ESP32。

  • UI Template (UID): 專屬的顯示視窗,用藍色大字體顯示最後一次掃描到的 RFID UID 號碼。

3. 資料庫維護管理 (下層流程)

這部分用於系統管理與資料清理。

  • UI Template (database command): 提供兩個維護按鈕。

    • 初始化 / 建立資料表:若 data_logs 表格不存在則建立它。

    • 清空紀錄:刪除資料表內所有資料(帶有彈出視窗確認)。

  • Function (126e9374abfdfcf8): 作為管理過濾器,確保只有點擊「維護按鈕」產生的 SQL 指令會進入資料庫執行,防止資料寫入錯誤。

4. 歷史紀錄顯示 (查詢流程)

這部分負責在儀表板上顯示過去的事件紀錄。

  • Inject (每 2 秒): 定時器,每隔 2 秒自動觸發一次資料庫查詢,確保表格資料是新的。

  • SQLite (2026-ex7): 執行 SELECT * FROM data_logs ORDER BY id DESC LIMIT 25,從資料庫撈出最後 25 筆資料。

  • UI Template (Show Data):

    • 將撈出的資料陣列(Array)渲染成 HTML 表格。

    • 內建邏輯:自動判斷訊息內容,如果是 UID: 則顯示藍色卡片圖標,如果是 ON/OFF 則顯示設定圖標。


整體架構總結

這是一個 「閉環控制 + 數據存儲」 的架構:

  1. 控制環:網頁按鈕 -> MQTT -> ESP32 動作 -> 狀態回傳 MQTT -> 網頁顯示燈號。

  2. 記錄環:ESP32 數據 -> MQTT -> Node-RED 格式化 -> SQLite 存檔。

  3. 顯示環:Inject 定時觸發 -> SQLite 查詢 -> UI 表格更新。

特別建議: 你的 Inject 節點目前設定為每 2 秒刷新一次,這在模擬器中可能會造成資料庫稍微忙碌,如果資料量大,可以考慮將頻率降為 5 秒或改為「寫入後觸發」。



沒有留言:

張貼留言

Wokwi ESP32 + Node-Red Dashboard UI Template + AngularJS

  Wokwi ESP32 + Node-Red Dashboard UI Template + AngularJS + RFID  (作業5) const char * mqtt_server = "broker.emqx.io" ; const cha...