2026年4月3日 星期五

Wokwi ESP32 + Node-Red Dashboard UI Template + AngularJS

Wokwi ESP32 +  Node-Red Dashboard UI Template + AngularJS







#include <WiFi.h>
#include <PubSubClient.h>
#include <DHT.h>
#include <ArduinoJson.h>

// Wi-Fi 憑證
const char* ssid = "Wokwi-GUEST";
const char* password = "";
const char* mqtt_broker = "mqttgo.io";
const int mqtt_port = 1883;

#define DHTPIN 4
#define DHTTYPE DHT22
DHT dht(DHTPIN, DHTTYPE);

const int ledPins[] = {13, 12, 14, 27};
const int numLeds = 4;

// MQTT Topics
const char* led_control_topic = "alex9ufo/ledcontrol";
const char* led_status_topic = "alex9ufo/ledstatus";
const char* temp_humi_topic = "alex9ufo/temphumi";

WiFiClient espClient;
PubSubClient client(espClient);

void setup_wifi() {
  delay(10);
  Serial.print("\nConnecting to "); Serial.println(ssid);
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println("\nWiFi connected. IP: " + WiFi.localIP().toString());
}

// 發布 LED 狀態與 Debug 訊息
void sendBackStatus() {
  StaticJsonDocument<200> statusDoc;
  statusDoc["led1"] = digitalRead(ledPins[0]);
  statusDoc["led2"] = digitalRead(ledPins[1]);
  statusDoc["led3"] = digitalRead(ledPins[2]);
  statusDoc["led4"] = digitalRead(ledPins[3]);
 
  char buffer[200];
  serializeJson(statusDoc, buffer);
  client.publish(led_status_topic, buffer);
 
  Serial.print("[TX Status] "); Serial.println(buffer);
}

// 處理訂閱訊息
void callback(char* topic, byte* payload, unsigned int length) {
  Serial.print("\n[RX Control] Topic: "); Serial.println(topic);
 
  StaticJsonDocument<200> doc;
  DeserializationError error = deserializeJson(doc, payload, length);
  if (error) return;

  int ledId = doc["id"];    
  int state = doc["state"];
 
  if(ledId >= 1 && ledId <= 4) {
    digitalWrite(ledPins[ledId-1], state);
    Serial.printf("DEBUG: LED %d set to %s\n", ledId, state ? "ON" : "OFF");
    sendBackStatus(); // 完成控制後回報新狀態
  }
}

// 重新連線機制
void reconnect() {
  while (!client.connected()) {
    Serial.print("Attempting MQTT connection...");
    if (client.connect("ESP32_Client_Alex")) {
      Serial.println("connected");
      client.subscribe(led_control_topic); // 重連後務必重新訂閱
    } else {
      Serial.print("failed, rc=");
      Serial.print(client.state());
      delay(5000);
    }
  }
}

void setup() {
  Serial.begin(115200);
  dht.begin();
  for(int i=0; i<numLeds; i++) {
    pinMode(ledPins[i], OUTPUT);
  }
  setup_wifi();
  client.setServer(mqtt_broker, mqtt_port);
  client.setCallback(callback); // 重要:註冊 Callback
}

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

  static unsigned long lastMsg = 0;
  if (millis() - lastMsg > 5000) { // 改為 5 秒傳一次比較穩定
    lastMsg = millis();
    float h = dht.readHumidity();
    float t = dht.readTemperature();
   
    if (!isnan(h) && !isnan(t)) {
      StaticJsonDocument<200> data;
      data["temp"] = t;
      data["humi"] = h;
      char buffer[200];
      serializeJson(data, buffer);
      client.publish(temp_humi_topic, buffer);
     
      Serial.print("[TX Sensor] "); Serial.print(t); Serial.print("C, "); Serial.print(h); Serial.println("%");
    } else {
      Serial.println("Failed to read from DHT sensor!");
    }
  }
}


Node-Red程式

[{"id":"6fee776ed1432473","type":"mqtt in","z":"3df70b2bf910d4a0","name":"","topic":"alex9ufo/temphumi","qos":"1","datatype":"auto-detect","broker":"192c2b20bef1e71a","nl":false,"rap":true,"rh":0,"inputs":0,"x":110,"y":180,"wires":[["a4a10947ee98d61c"]]},{"id":"f0cd3b565c12d25e","type":"mqtt in","z":"3df70b2bf910d4a0","name":"","topic":"alex9ufo/ledstatus","qos":"1","datatype":"auto-detect","broker":"192c2b20bef1e71a","nl":false,"rap":true,"rh":0,"inputs":0,"x":110,"y":240,"wires":[["a4a10947ee98d61c"]]},{"id":"a4a10947ee98d61c","type":"json","z":"3df70b2bf910d4a0","name":"","property":"payload","action":"","pretty":false,"x":290,"y":200,"wires":[["47079334473fc103","080bc85b17a642e3"]]},{"id":"51c209dc2b2b3dbd","type":"mqtt out","z":"3df70b2bf910d4a0","name":"","topic":"alex9ufo/ledcontrol","qos":"1","retain":"","respTopic":"","contentType":"","userProps":"","correl":"","expiry":"","broker":"192c2b20bef1e71a","x":810,"y":180,"wires":[]},{"id":"47079334473fc103","type":"debug","z":"3df70b2bf910d4a0","name":"debug 376","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"false","statusVal":"","statusType":"auto","x":450,"y":160,"wires":[]},{"id":"ae5ae9dcd87cd424","type":"debug","z":"3df70b2bf910d4a0","name":"debug 377","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"false","statusVal":"","statusType":"auto","x":790,"y":220,"wires":[]},{"id":"080bc85b17a642e3","type":"function","z":"3df70b2bf910d4a0","name":"(溫度判斷)","func":"// 確保 payload 是物件\nif (typeof msg.payload === \"string\") {\n    msg.payload = JSON.parse(msg.payload);\n}\n\nvar temp = msg.payload.temp;\n\n// 邏輯判斷\nif (temp > 70) {\n    msg.status = \"危險:極高溫!\";\n    msg.color = \"red\";\n} else if (temp > 30) {\n    msg.status = \"警告:溫度偏高\";\n    msg.color = \"orange\";\n} else {\n    msg.status = \"溫度正常\";\n    msg.color = \"green\";\n}\n\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":450,"y":200,"wires":[["337a5f79ec4f3683"]]},{"id":"337a5f79ec4f3683","type":"ui_template","z":"3df70b2bf910d4a0","group":"63fff320de16e3a7","name":"","order":0,"width":0,"height":0,"format":"<div layout=\"column\" style=\"background: #2b2b2b; color: #eee; padding: 15px; border-radius: 10px;\">\n\n    <div layout=\"row\" layout-align=\"space-around center\"\n        style=\"margin-bottom: 15px; background: #1a1a1a; padding: 10px; border-radius: 8px;\">\n        <div style=\"text-align: center;\">\n            <div style=\"font-size: 0.8em; color: #888;\">TEMPERATURE</div>\n            <div style=\"font-size: 1.8em; color: #ff9800;\">{{status.temp ||\n                '--'}}<span style=\"font-size: 0.6em;\">°C</span></div>\n        </div>\n        <div style=\"text-align: center;\">\n            <div style=\"font-size: 0.8em; color: #888;\">HUMIDITY</div>\n            <div style=\"font-size: 1.8em; color: #03a9f4;\">{{status.humi ||\n                '--'}}<span style=\"font-size: 0.6em;\">%</span></div>\n        </div>\n    </div>\n\n    <div ng-repeat=\"i in [1,2,3,4]\" layout=\"row\" layout-align=\"space-between center\"\n        style=\"padding: 8px; border-bottom: 1px solid #444;\">\n\n        <div style=\"font-weight: bold; width: 50px;\">LED {{i}}</div>\n\n        <div ng-style=\"{'background-color': (status['led'+i] == 1 || status['led'+i] == '1') ? '#00FF00' : '#FF0000'}\"\n            style=\"width: 18px; height: 18px; border-radius: 50%; box-shadow: 0 0 10px rgba(0,0,0,0.5); transition: 0.3s;\">\n        </div>\n\n        <div layout=\"row\">\n            <md-button class=\"md-raised\" style=\"background: #2e7d32; color: white; min-width: 45px; margin: 0 4px;\"\n                ng-click=\"ctrl(i, 1)\">ON</md-button>\n            <md-button class=\"md-raised\" style=\"background: #c62828; color: white; min-width: 45px; margin: 0 4px;\"\n                ng-click=\"ctrl(i, 0)\">OFF</md-button>\n        </div>\n    </div>\n</div>\n\n<script>\n    (function(scope) {\n        // 初始化狀態容器\n        scope.status = { temp: '--', humi: '--', led1: 0, led2: 0, led3: 0, led4: 0 };\n\n        // 監聽傳入的 msg\n        scope.$watch('msg', function(msg) {\n            if (!msg || !msg.payload) return;\n\n            var newData = msg.payload;\n\n            // 確保資料是物件格式\n            if (typeof newData === 'string') {\n                try {\n                    newData = JSON.parse(newData);\n                } catch (e) { return; }\n            }\n\n            // 使用 Angular 的方式合併資料並強制更新畫面\n            scope.$evalAsync(function() {\n                // 將新資料合併到 status 物件中\n                angular.extend(scope.status, newData);\n            });\n        });\n\n        // 發送控制指令\n        scope.ctrl = function(id, state) {\n            scope.send({\n                topic: \"alex9ufo/ledcontrol\",\n                payload: { \"id\": id, \"state\": state }\n            });\n        };\n    })(scope);\n</script>","storeOutMessages":true,"fwdInMessages":true,"resendOnRefresh":true,"templateScope":"local","className":"","x":620,"y":180,"wires":[["51c209dc2b2b3dbd","ae5ae9dcd87cd424"]]},{"id":"192c2b20bef1e71a","type":"mqtt-broker","name":"mqttgo","broker":"mqttgo.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":"63fff320de16e3a7","type":"ui_group","name":"Dashboard UI Template","tab":"8c00e350715570d8","order":1,"disp":true,"width":"6","collapse":false,"className":""},{"id":"8c00e350715570d8","type":"ui_tab","name":"2026-ex6","icon":"dashboard","disabled":false,"hidden":false}]


Function 節點

// 確保 payload 是物件
if (typeof msg.payload === "string") {
    msg.payload = JSON.parse(msg.payload);
}

var temp = msg.payload.temp;

// 邏輯判斷
if (temp > 70) {
    msg.status = "危險:極高溫!";
    msg.color = "red";
} else if (temp > 30) {
    msg.status = "警告:溫度偏高";
    msg.color = "orange";
} else {
    msg.status = "溫度正常";
    msg.color = "green";
}

return msg;


template 節點  (AngularJS)

<div layout="column" style="background: #2b2b2b; color: #eee; padding: 15px; border-radius: 10px;">

    <div layout="row" layout-align="space-around center"
        style="margin-bottom: 15px; background: #1a1a1a; padding: 10px; border-radius: 8px;">
        <div style="text-align: center;">
            <div style="font-size: 0.8em; color: #888;">TEMPERATURE</div>
            <div style="font-size: 1.8em; color: #ff9800;">{{status.temp ||
                '--'}}<span style="font-size: 0.6em;">°C</span></div>
        </div>
        <div style="text-align: center;">
            <div style="font-size: 0.8em; color: #888;">HUMIDITY</div>
            <div style="font-size: 1.8em; color: #03a9f4;">{{status.humi ||
                '--'}}<span style="font-size: 0.6em;">%</span></div>
        </div>
    </div>

    <div ng-repeat="i in [1,2,3,4]" layout="row" layout-align="space-between center"
        style="padding: 8px; border-bottom: 1px solid #444;">

        <div style="font-weight: bold; width: 50px;">LED {{i}}</div>

        <div ng-style="{'background-color': (status['led'+i] == 1 || status['led'+i] == '1') ? '#00FF00' : '#FF0000'}"
            style="width: 18px; height: 18px; border-radius: 50%; box-shadow: 0 0 10px rgba(0,0,0,0.5); transition: 0.3s;">
        </div>

        <div layout="row">
            <md-button class="md-raised" style="background: #2e7d32; color: white; min-width: 45px; margin: 0 4px;"
                ng-click="ctrl(i, 1)">ON</md-button>
            <md-button class="md-raised" style="background: #c62828; color: white; min-width: 45px; margin: 0 4px;"
                ng-click="ctrl(i, 0)">OFF</md-button>
        </div>
    </div>
</div>

<script>
    (function(scope) {
        // 初始化狀態容器
        scope.status = { temp: '--', humi: '--', led1: 0, led2: 0, led3: 0, led4: 0 };

        // 監聽傳入的 msg
        scope.$watch('msg', function(msg) {
            if (!msg || !msg.payload) return;

            var newData = msg.payload;

            // 確保資料是物件格式
            if (typeof newData === 'string') {
                try {
                    newData = JSON.parse(newData);
                } catch (e) { return; }
            }

            // 使用 Angular 的方式合併資料並強制更新畫面
            scope.$evalAsync(function() {
                // 將新資料合併到 status 物件中
                angular.extend(scope.status, newData);
            });
        });

        // 發送控制指令
        scope.ctrl = function(id, state) {
            scope.send({
                topic: "alex9ufo/ledcontrol",
                payload: { "id": id, "state": state }
            });
        };
    })(scope);
</script>


2026年4月2日 星期四

Node-Red 常見安裝錯誤與解決方案

Node-Red 常見安裝錯誤與解決方案

 安裝 Node-RED 失敗通常是因為 Node.js 版本不相容、權限不足(Windows 使用 sudo  --unsafe-perm)或 npm 快取問題。建議先確認 Node.js (LTS 版本) 安裝,並使用管理員權限執行安裝指令 npm install -g --unsafe-perm node-red

常見安裝錯誤與解決方案
  • 權限問題 (EACCES/Permissions):
    • 原因: Linux/macOS 下全域安裝缺少 sudo
    • 解決方案: 使用 sudo npm install -g --unsafe-perm node-red
  • 權限問題 (Windows):
    • 解決方案: 以「系統管理員身分」開啟 cmd 或 PowerShell 再執行指令。
  • Node.js 版本不相容:
    • 解決方案: Node-RED 建議使用 Node.js LTS 版本 (例如 v18 或 v20)。若安裝較新版本失敗,請下載並安裝最新的 Node.js LTS
  • Node-gyp 錯誤 (依賴庫編譯失敗):
    • 解決方案: 需要環境配置 (Python, C++ 編譯器)。嘗試安裝編譯工具: npm install --global windows-build-tools (Windows 管理員下執行)。
  • 安裝卡住或網路錯誤:
    • 解決方案: 清除快取並重試:npm cache clean --force
基本安裝指令 (Windows/macOS/Linux)
bash
# 1. 檢查 node 和 npm 版本 (確認安裝了 LTS 版本)
node --version
npm --version

# 2. 全域安裝 Node-RED (加上 --unsafe-perm 解決權限導致的安裝失敗)
npm install -g --unsafe-perm node-red

# 3. 啟動 Node-RED
node-red

2026年3月30日 星期一

2026 作業4 WOKWI ESP32 DHT22+4LED & Node-Red , Telegram , MQTT 練習

2026 作業4   WOKWI ESP32 DHT22+4LED & Node-Red , Telegram , MQTT 練習


Node-Red Telegram 設定需修改  TOKEN API  與 CHATID  才會指向你的Telegram

 

IoT 遠端控制與監測系統。它是一個典型的「雲端代理(MQTT Broker)」架構,讓原本互相隔離的 Wokwi 模擬器、Node-RED 儀表板與 Telegram 手機端能夠即時溝通。

核心通訊流程:MQTT 作為調度中心

整個系統圍繞著 Mqttgo.io 這個 Broker 運作。所有的指令與數據都是透過「訂閱(Subscribe)」與「發布(Publish)」來傳遞。

1. 指令流 (從手機到硬體)

這是一條「下行控制」路徑,主要負責開關 LED:

  • 來源:您在 Telegram 輸入指令(如 /1on)或在 Node-RED Dashboard 按下開關。

  • 中轉:Node-RED 接收到 Telegram 的訊息後,將其轉換為簡單的字串(如 1on),並發布到 MQTT 主題:alex9ufo/ledcontrol

  • 執行Wokwi 中的 ESP32 因為訂閱了這個主題,會立刻收到訊息並透過 core0Task 驅動 GPIO 腳位,點亮對應的 LED。

2. 狀態回報流 (從硬體到手機/網頁)

這是一條「確認機制」路徑,確保指令確實執行:

  • 來源:ESP32 成功切換 LED 電位後,由 core1Task 將結果(如 LED1 ON)發布到:alex9ufo/ledstatus

  • 顯示Node-RED 訂閱此主題後,更新儀表板上的指示燈顏色,並將成功訊息回傳給 Telegram

3. 感測器數據流 (DHT22 監測)

這是一條「數據監測」路徑:

  • 觸發:在 Wokwi 序列監控視窗按 Enter,或從 Telegram 發送 readData

  • 數據:ESP32 讀取 DHT22 後,將字串(Temperature: 5.00C...)發布到:alex9ufo/temphumi

  • 應用:Node-RED 接收後,透過您寫的 Function 節點 拆分數據,顯示在儀表板的圓形進度條(Gauge)上。


關鍵技術亮點

組件主要角色圖片中呈現的功能
Wokwi (ESP32)執行器與感測端運行雙核心程式,負責 WiFi 連線、LED 硬體控制與 DHT22 採集。
Node-RED大腦與中間層處理邏輯判斷(如溫度 > 70 警報)、介面顯示與 Telegram 訊息轉譯。
Telegram遠端介面提供跨地域的文字指令操作,並接收高溫或狀態通知。
MQTT (Mqttgo)訊息郵局確保不同網域下的設備能透過 Topic 機制進行雙向通訊。

「手動方式要按 Enter」

這點非常重要。由於 Wokwi 環境的限制,DHT22 的數據並非每秒自動狂噴,而是採用被動式觸發

  1. 本地觸發:在模擬器的 Serial 視窗按 Enter。

  2. 遠端觸發:從 Telegram 發送 /readData。 這是一個很好的設計,可以避免 MQTT Broker 在模擬測試期間產生過多不必要的數據流量。


Telegram 控制指令

歡迎 Alex 使用 WOKWI&Node-red+Telegram控制系統 /1on : 開啟 LED1 /1off : 關閉 LED1 /2on : 開啟 LED2 /2off : 關閉 LED2 /3on : 開啟 LED3 /3off : 關閉 LED3 /4on : 開啟 LED4 /4off : 關閉 LED4 /allon : 開啟 LED1-LED4 /alloff : 關閉 LED1-LED4 /WOKWI 的 DHT22 輸出 採用 手動方式 要按enter


MQTT Broker與Topic 設定

const char* mqtt_broker = "mqttgo.io";

const int mqtt_port = 1883;

// MQTT 主題
const char* led_control_topic = "alex9ufo/ledcontrol";
const char* led_status_topic = "alex9ufo/ledstatus";
const char* temp_humi_topic = "alex9ufo/temphumi";

ESP32接腳

#define DHTPIN 4     // DHT22 連接到 ESP32 的 D4 腳位
// LED 腳位定義 (根據 diagram.json 設定)
const int ledPins[] = {13, 12, 14, 27}; // 假設連接到 D13, D12, D14, D27


WOKWIESP32程式




#include <WiFi.h>

#include <PubSubClient.h>
#include <Adafruit_Sensor.h>
#include <DHT.h>
#include <DHT_U.h>
#include <freertos/FreeRTOS.h> // 引入 FreeRTOS 庫
#include <freertos/task.h>     // 引入 FreeRTOS 任務相關庫

// Wi-Fi 憑證
const char* ssid = "Wokwi-GUEST";     // 替換成您的 Wi-Fi 名稱 (例如 Wokwi 的 "Wokwi-GUEST")
const char* password = "";            // 替換成您的 Wi-Fi 密碼 (例如 Wokwi 的空字串 "")

#define DHTPIN 4     // DHT22 連接到 ESP32 的 D4 腳位
#define DHTTYPE DHT22 // DHT 22 (AM2302), AM2321

DHT_Unified dht(DHTPIN, DHTTYPE);

const char* mqtt_broker = "mqttgo.io";
const int mqtt_port = 1883;
const char* mqtt_client_id = "alex9ufo-wokwi-client-dualcore";

// MQTT 主題
const char* led_control_topic = "alex9ufo/ledcontrol";
const char* led_status_topic = "alex9ufo/ledstatus";
const char* temp_humi_topic = "alex9ufo/temphumi";

// LED 腳位定義 (根據 diagram.json 設定)
const int ledPins[] = {13, 12, 14, 27}; // 假設連接到 D13, D12, D14, D27
const int NUM_LEDS = sizeof(ledPins) / sizeof(ledPins[0]);

WiFiClient espClient;
PubSubClient client(espClient);

// 用於跨任務通訊的佇列 (Queue)
// 我們將使用這個佇列來傳遞要發布的 MQTT 訊息
QueueHandle_t mqttPublishQueue;

// 共享變數,用於通知發布任務 DHT22 數據可用
volatile bool dhtReadTriggered = false;
volatile bool dhtReadData = false;
volatile float lastTemperature = 0.0;
volatile float lastHumidity = 0.0;

void setup_wifi() {
  delay(10);
  Serial.println();
  Serial.print("Connecting to ");
  Serial.println(ssid);

  WiFi.begin(ssid, password);

  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }

  Serial.println("");
  Serial.println("WiFi connected");
  Serial.println("IP address: ");
  Serial.println(WiFi.localIP());
}

// MQTT 訂閱的回調函數 (在 Core 0 執行)
void mqttSubscribeCallback(char* topic, byte* payload, unsigned int length) {
  Serial.print("Message arrived [");
  Serial.print(topic);
  Serial.print("] ");
  payload[length] = '\0'; // 確保字串結束
  String message = String((char*)payload);
  Serial.println(message);

  if (String(topic) == led_control_topic) {
    String publishMsg = ""; // 用於發布的訊息

    if (message == "1on") {
      digitalWrite(ledPins[0], HIGH);
      publishMsg = "LED1 ON";
      Serial.println("LED1 ON");
    } else if (message == "1off") {
      digitalWrite(ledPins[0], LOW);
      publishMsg = "LED1 OFF";
      Serial.println("LED1 OFF");
    } else if (message == "2on") {
      digitalWrite(ledPins[1], HIGH);
      publishMsg = "LED2 ON";
      Serial.println("LED2 ON");
    } else if (message == "2off") {
      digitalWrite(ledPins[1], LOW);
      publishMsg = "LED2 OFF";
      Serial.println("LED2 OFF");
    } else if (message == "3on") {
      digitalWrite(ledPins[2], HIGH);
      publishMsg = "LED3 ON";
      Serial.println("LED3 ON");
    } else if (message == "3off") {
      digitalWrite(ledPins[2], LOW);
      publishMsg = "LED3 OFF";
      Serial.println("LED3 OFF");
    } else if (message == "4on") {
      digitalWrite(ledPins[3], HIGH);
      publishMsg = "LED4 ON";
      Serial.println("LED4 ON");
    } else if (message == "4off") {
      digitalWrite(ledPins[3], LOW);
      publishMsg = "LED4 OFF";
      Serial.println("LED4 OFF");
    } else if (message == "allon") {
      for (int i = 0; i < NUM_LEDS; i++) {
        digitalWrite(ledPins[i], HIGH);
      }
      publishMsg = "ALL LEDs ON";
      Serial.println("ALL LEDs ON");
    } else if (message == "alloff") {
      for (int i = 0; i < NUM_LEDS; i++) {
        digitalWrite(ledPins[i], LOW);
      }
      publishMsg = "ALL LEDs OFF";
      Serial.println("ALL LEDs OFF");
    } else if (message == "readData") {
      Serial.println("讀取DHT22 Data 從Telehgram送出命令");
      publishMsg = "讀取DHT22 Data";
      dhtReadData = true;
    }  


    // 將要發布的訊息放入佇列,由發布任務處理
    if (publishMsg.length() > 0) {
      char msgBuffer[50];
      publishMsg.toCharArray(msgBuffer, sizeof(msgBuffer));
      xQueueSend(mqttPublishQueue, &msgBuffer, portMAX_DELAY);
    }
  }
}

void reconnect() {
  // Loop until we're reconnected
  while (!client.connected()) {
    Serial.print("Attempting MQTT connection...");
    // Attempt to connect
    if (client.connect(mqtt_client_id)) {
      Serial.println("connected");
      // Once connected, publish an announcement...
      char initialMsg[] = "Wokwi LED controller connected (Dual Core)";
      xQueueSend(mqttPublishQueue, &initialMsg, portMAX_DELAY); // 將初始化訊息放入佇列
      // ... and resubscribe
      client.subscribe(led_control_topic);
    } else {
      Serial.print("failed, rc=");
      Serial.print(client.state());
      Serial.println(" try again in 5 seconds");
      // Wait 5 seconds before retrying
      delay(5000);
    }
  }
}

// DHT22 讀取函數 (在 Core 0 觸發,數據由發布任務處理)
void readDHT22() {
  sensors_event_t event;
  dht.temperature().getEvent(&event);
  if (isnan(event.temperature)) {
    Serial.println(F("Error reading temperature!"));
  } else {
    lastTemperature = event.temperature; // 更新共享變數
    dht.humidity().getEvent(&event);
    if (isnan(event.relative_humidity)) {
      Serial.println(F("Error reading humidity!"));
    } else {
      lastHumidity = event.relative_humidity; // 更新共享變數
      dhtReadTriggered = true; // 設定旗標,通知發布任務有新數據
    }
  }
}

// Core 0 任務:處理 MQTT 訂閱和 Serial Monitor 輸入
void core0Task(void * parameter) {
  Serial.println("Core 0 Task running: Handling MQTT Subscriptions and Serial Input.");
  client.setCallback(mqttSubscribeCallback); // 設定訂閱回調函數

  while (true) {
    if (!client.connected()) {
      reconnect();
    }
    client.loop(); // 處理 MQTT 訂閱訊息

    // 監聽 Serial Monitor 輸入
    if (Serial.available()) {
      String command = Serial.readStringUntil('\n');
      command.trim(); // 移除前後空白

      if (command == "") { // 當使用者按下 Enter (輸入空字串)
        readDHT22(); // 讀取 DHT22
      }
    }
    if (dhtReadData) {
      dhtReadData=false;
      readDHT22(); // 讀取 DHT22
    }

    vTaskDelay(10 / portTICK_PERIOD_MS); // 短暫延遲,讓其他任務有執行機會
  }
}

// Core 1 任務:處理 MQTT 發布
void core1Task(void * parameter) {
  Serial.println("Core 1 Task running: Handling MQTT Publications.");
  char msgBuffer[50];

  while (true) {
    // 檢查是否有要從佇列發布的訊息
    if (xQueueReceive(mqttPublishQueue, &msgBuffer, 0) == pdTRUE) {
      if (client.connected()) {
        if (strcmp(msgBuffer, "LED1 ON") == 0 || strcmp(msgBuffer, "LED1 OFF") == 0 ||
            strcmp(msgBuffer, "LED2 ON") == 0 || strcmp(msgBuffer, "LED2 OFF") == 0 ||
            strcmp(msgBuffer, "LED3 ON") == 0 || strcmp(msgBuffer, "LED3 OFF") == 0 ||
            strcmp(msgBuffer, "LED4 ON") == 0 || strcmp(msgBuffer, "LED4 OFF") == 0 ||
            strcmp(msgBuffer, "ALL LEDs ON") == 0 || strcmp(msgBuffer, "ALL LEDs OFF") == 0) {
          client.publish(led_status_topic, msgBuffer);
          Serial.print("Published to led_status_topic: ");
          Serial.println(msgBuffer);
        }
      }
    }

    // 檢查是否有 DHT22 新數據要發布
    if (dhtReadTriggered) {
      if (client.connected()) {
        String temp_humi_msg = "Temperature: " + String(lastTemperature) + "C, Humidity: " + String(lastHumidity) + "%";
        client.publish(temp_humi_topic, temp_humi_msg.c_str());
        Serial.print("Published to temp_humi_topic: ");
        Serial.println(temp_humi_msg);
      }
      dhtReadTriggered = false; // 重置旗標
    }

    vTaskDelay(50 / portTICK_PERIOD_MS); // 適度延遲,避免佔用所有 CPU 資源
  }
}

void setup() {
  Serial.begin(115200);

  for (int i = 0; i < NUM_LEDS; i++) {
    pinMode(ledPins[i], OUTPUT);
    digitalWrite(ledPins[i], LOW); // 初始化所有 LED 為關閉
  }

  dht.begin();
  sensor_t sensor;
  dht.temperature().getSensor(&sensor);
  Serial.println(F("------------------------------------"));
  Serial.println(F("Unified Sensor Example"));
  Serial.print(F("Sensor:       ")); Serial.println(sensor.name);
  Serial.print(F("Driver Ver:   ")); Serial.println(sensor.version);
  Serial.print(F("Unique ID:    ")); Serial.println(sensor.sensor_id);
  Serial.print(F("Max Value:    ")); Serial.print(sensor.max_value); Serial.println(F(" C"));
  Serial.print(F("Min Value:    ")); Serial.print(sensor.min_value); Serial.println(F(" C"));
  Serial.print(F("Resolution:   ")); Serial.print(sensor.resolution); Serial.println(F(" C"));
  Serial.println(F("------------------------------------"));

  setup_wifi();
  client.setServer(mqtt_broker, mqtt_port);

  // 創建用於跨任務通訊的佇列
  mqttPublishQueue = xQueueCreate(10, sizeof(char[50])); // 佇列深度為 10,每個元素為 50 字元的 char 陣列

  // 創建 Core 0 任務 (處理訂閱)
  xTaskCreatePinnedToCore(
    core0Task,          // 任務函數
    "Core0Task",        // 任務名稱
    10000,              // 任務堆疊大小 (位元組)
    NULL,               // 傳遞給任務的參數
    1,                  // 任務優先級 (較高優先級)
    NULL,               // 任務句柄
    0                   // 運行在 Core 0
  );

  // 創建 Core 1 任務 (處理發布)
  xTaskCreatePinnedToCore(
    core1Task,          // 任務函數
    "Core1Task",        // 任務名稱
    10000,              // 任務堆疊大小 (位元組)
    NULL,               // 傳遞給任務的參數
    1,                  // 任務優先級
    NULL,               // 任務句柄
    1                   // 運行在 Core 1
  );
}

void loop() {
  // loop() 函數在 FreeRTOS 環境下,如果所有任務都已建立,通常會保持空閒。
  // 所有邏輯都將在各自的任務中運行。
  vTaskDelete(NULL); // 刪除 loop 任務本身,將控制權完全交給 FreeRTOS
}


這份程式碼是一個非常進階的 ESP32 雙核心 (Dual Core) 應用範例,利用了 ESP32 內部的兩個處理核心(Core 0 與 Core 1)並結合 FreeRTOS 作業系統來分工處理物聯網任務。

其核心邏輯是:Core 0 負責「聽」指令(MQTT 訂閱與 Serial 輸入),Core 1 負責「說」話(MQTT 數據發布)。


程式碼逐行詳解

1. 引用庫與定義 (Includes & Defines)

  • WiFi.h & PubSubClient.h: 處理 Wi-Fi 連線與 MQTT 協定。

  • DHT.h & DHT_U.h: 使用 Adafruit 的統一感測器庫來讀取 DHT22 溫濕度。

  • freertos/FreeRTOS.h & task.h: 這是關鍵,引入 FreeRTOS 核心功能,讓 ESP32 能夠執行多工任務。

  • DHTPIN 4: 定義 DHT22 接在 GPIO 4。

2. 全域變數與跨核心通訊

  • QueueHandle_t mqttPublishQueue: 這是 「佇列」。因為兩個核心不能直接隨意存取對方的區域變數,所以建立一個「傳聲筒」,Core 0 把要發出的訊息丟進去,Core 1 再從裡面拿出來發送。

  • volatile bool dhtReadTriggered: 加上 volatile 關鍵字,告訴編譯器這個變數會在不同的核心(任務)之間變動,確保讀取到的是最新值。

3. MQTT 回調函數 (處理接收到的指令)

  • mqttSubscribeCallback: 當 Node-RED 或 Telegram 傳送指令(如 1on, alloff)到 ledcontrol 主題時,這個函數會被觸發。

  • 指令判斷: 程式會比對 message,執行 digitalWrite 切換 LED 電位。

  • xQueueSend: 執行完動作後,將「LED1 ON」等回應文字塞進 mqttPublishQueue 佇列,交給 Core 1 去回報狀態。

4. 雙核心任務 (Tasks)

Core 0 任務:core0Task (處理「入」的資料)

  • client.loop(): 這是 MQTT 的心臟,負責檢查是否有新訊息進來。

  • Serial 監聽: 檢查電腦序列埠輸入。如果你在 Wokwi 按下 Enter(空字串),它會觸發 readDHT22()

  • dhtReadData: 如果從 Telegram 收到 readData 指令,也會觸發讀取。

Core 1 任務:core1Task (處理「出」的資料)

  • xQueueReceive: 持續檢查「傳聲筒(佇列)」裡有沒有訊息。如果有(如:初始化訊息或 LED 狀態),就執行 client.publish 發送出去。

  • dhtReadTriggered 檢查: 當 Core 0 完成 DHT22 讀取後會舉起這個旗標,Core 1 看到後就會將溫度與濕度組合成字串,發布到 temphumi 主題。

5. 初始化與核心分配 (setup)

  • xQueueCreate: 初始化佇列空間。

  • xTaskCreatePinnedToCore: 這是最重要的一步:

    • 建立 Core0Task 並指派給 Core 0

    • 建立 Core1Task 並指派給 Core 1

    • 這確保了當感測器讀取或 Wi-Fi 重連時,不會卡住 LED 的控制反應。

6. 主迴圈 (loop)

  • vTaskDelete(NULL): 在典型的 FreeRTOS 程式中,我們不在 loop() 裡寫程式。這行代碼會刪除原本 Arduino 預設的 loop 任務,釋放資源給我們自定義的兩個核心任務。


程式優點總結

  1. 非阻塞 (Non-blocking):傳統做法如果 Wi-Fi 斷線,整個程式會卡在 reconnect 裡,LED 就無法控制。但在這個版本中,Core 0 依然可以處理本地指令。

  2. 即時性:利用佇列(Queue)緩衝訊息,避免在高頻率傳輸時遺失數據。

  3. 靈活性:支援手動(Enter 鍵)與遠端(Telegram 指令)兩種方式觸發 DHT22 讀取。


Node-Red程式




[{"id":"aa38bbd69640e2ae","type":"telegram receiver","z":"8c2053d55f5883f8","name":"","bot":"457874f8aa8a857a","saveDataDir":"","filterCommands":true,"x":130,"y":40,"wires":[["eb8234cf9675d55a"],[]]},{"id":"60bd77976d9b25a8","type":"function","z":"8c2053d55f5883f8","name":"根據指令發佈 MQTT 訊息","func":"if (msg.payload === \"/1on\") {\n    msg.topic = \"alex9ufo/ledcontrol\";\n    msg.payload = \"1on\";  // 開啟 LED1\n    return [msg , null];\n} else if (msg.payload === \"/1off\") {\n    msg.topic = \"alex9ufo/ledcontrol\";\n    msg.payload = \"1off\"; // 關閉 LED1\n    return [msg , null];\n} else if (msg.payload === \"/2on\") {\n    msg.topic = \"alex9ufo/ledcontrol\";\n    msg.payload = \"2on\"; // 開啟 LED2\n    return [msg , null];\n} else if (msg.payload === \"/2off\") {\n    msg.topic = \"alex9ufo/ledcontrol\";\n    msg.payload = \"2off\"; // 關閉 LED2\n    return [msg , null];\n} else if (msg.payload === \"/3on\") {\n    msg.topic = \"alex9ufo/ledcontrol\";\n    msg.payload = \"3on\"; // 開啟 LED3\n    return [msg , null];\n} else if (msg.payload === \"/3off\") {\n    msg.topic = \"alex9ufo/ledcontrol\";\n    msg.payload = \"3off\"; // 關閉 LED3\n    return [msg , null];\n} else if (msg.payload === \"/4on\") {\n    msg.topic = \"alex9ufo/ledcontrol\";\n    msg.payload = \"4on\"; // 開啟 LED4\n    return [msg , null];\n} else if (msg.payload === \"/4off\") {\n    msg.topic = \"alex9ufo/ledcontrol\";\n    msg.payload = \"4off\"; // 關閉 LED4\n    return [msg , null];\n} else if (msg.payload === \"/allon\") {\n    msg.topic = \"alex9ufo/ledcontrol\";\n    msg.payload = \"allon\"; // 開啟 all LED1-LED4\n    return [msg , null];\n} else if (msg.payload === \"/alloff\") {\n    msg.topic = \"alex9ufo/ledcontrol\";\n    msg.payload = \"alloff\"; // 關閉 all LED1-LED4\n    return [msg , null];\n} else if  (msg.payload === \"/WOKWI\") {\n    msg.topic = \"alex9ufo/ledcontrol\";\n    msg.payload = \"readData\";  // 讀取DHT22溫度濕度\n    return [msg, null];\n} \n\n//function 根據指令來發佈 MQTT 訊息","outputs":2,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":510,"y":40,"wires":[["2579e03bbb9008db","3f077821c764e91d"],[]]},{"id":"2579e03bbb9008db","type":"mqtt out","z":"8c2053d55f5883f8","name":"alex9ufo/ledcontrol","topic":"alex9ufo/ledcontrol","qos":"1","retain":"true","respTopic":"","contentType":"","userProps":"","correl":"","expiry":"","broker":"192c2b20bef1e71a","x":790,"y":60,"wires":[]},{"id":"f04e44b6863fba43","type":"mqtt in","z":"8c2053d55f5883f8","name":"","topic":"alex9ufo/ledstatus","qos":"2","datatype":"auto-detect","broker":"192c2b20bef1e71a","nl":false,"rap":true,"rh":0,"inputs":0,"x":130,"y":260,"wires":[["c4c0948da43caba8","81ada557b64dc604","5df891b22fcf0d78","d20c7a1c503d7b74","9a49877e65ede3d6"]]},{"id":"eb8234cf9675d55a","type":"function","z":"8c2053d55f5883f8","name":"function","func":"var content= msg.payload.content;\nmsg.payload=content;\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":320,"y":40,"wires":[["60bd77976d9b25a8","85fff350d422330f"]]},{"id":"7285f33b22258e53","type":"ui_led","z":"8c2053d55f5883f8","order":1,"group":"9123b13576984e5e","width":3,"height":3,"label":"LED1","labelPlacement":"left","labelAlignment":"left","colorForValue":[{"color":"#ff0000","value":"false","valueType":"bool"},{"color":"#008000","value":"true","valueType":"bool"}],"allowColorForValueInMessage":false,"shape":"circle","showGlow":true,"name":"LED1","x":570,"y":180,"wires":[]},{"id":"c4c0948da43caba8","type":"function","z":"8c2053d55f5883f8","name":"function","func":"var content = msg.payload;\n\nif (content === 'LED1 ON') {\n    msg.payload = true;\n    // Route to output 1\n    return [msg, null, null, null ];\n}\nif (content === 'LED1 OFF') {\n    msg.payload = false;\n    // Route to output 1\n    return [msg, null, null, null];\n}\nif (content === 'LED2 ON') {   \n    msg.payload = true;\n    // Route to output 2\n    return [null, msg, null, null];\n}\nif (content === 'LED2 OFF') {\n    msg.payload = false;\n    // Route to output 2\n    return [null, msg, null, null];\n}\nif (content === 'LED3 ON') {   \n    msg.payload = true;\n    // Route to output 3\n    return [null,  null, msg, null];\n}\nif (content === 'LED3 OFF') {\n    msg.payload = false;\n    // Route to output 3\n    return [null, null, msg, null];\n}\nif (content === 'LED4 ON') {\n    msg.payload = true;\n    // Route to output 4\n    return [null, null, null, msg];\n}\nif (content === 'LED4 OFF') {\n    msg.payload = false;\n    // Route to output 4\n    return [null, null, null, msg];\n}\n\nif (content === 'ALL LEDs ON') {\n    msg.payload = true;\n    // Route to output 1-4\n    return [msg, msg, msg, msg];\n}\nif (content === 'ALL LEDs OFF') {\n    msg.payload = false;\n    // Route to output 1-4\n    return [msg, msg, msg, msg];\n}\n\n\n\nreturn null;","outputs":4,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":340,"y":260,"wires":[["7285f33b22258e53"],["38f4e014de0658f6"],["41c06dba6c2f63a0"],["be68d0fe89c4fb10"]]},{"id":"b7fcc81b89152e02","type":"telegram sender","z":"8c2053d55f5883f8","name":"","bot":"457874f8aa8a857a","haserroroutput":false,"outputs":1,"x":510,"y":360,"wires":[[]]},{"id":"ede1cfbc300148a2","type":"debug","z":"8c2053d55f5883f8","name":"debug 371","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"false","statusVal":"","statusType":"auto","x":490,"y":400,"wires":[]},{"id":"81ada557b64dc604","type":"template","z":"8c2053d55f5883f8","name":"","field":"payload","fieldType":"msg","format":"handlebars","syntax":"mustache","template":"{\"chatId\": 7965218469,\n\"type\":\"message\",\n\"content\":\"{{payload}}\"}","output":"json","x":290,"y":360,"wires":[["b7fcc81b89152e02","ede1cfbc300148a2"]]},{"id":"e3993085c203bdba","type":"comment","z":"8c2053d55f5883f8","name":"MQTT 設定","info":"**const char* mqtt_broker = \"mqttgo.io\";\nconst int mqtt_port = 1883;\nconst char* mqtt_client_id = \"alex9ufo-wokwi-client-dualcore\";\n\n// MQTT 主題\nconst char* led_control_topic = \"alex9ufo/ledcontrol\";\nconst char* led_status_topic = \"alex9ufo/ledstatus\";\nconst char* temp_humi_topic = \"alex9ufo/temphumi\"; ","x":110,"y":360,"wires":[]},{"id":"38f4e014de0658f6","type":"ui_led","z":"8c2053d55f5883f8","order":2,"group":"9123b13576984e5e","width":3,"height":3,"label":"LED2","labelPlacement":"left","labelAlignment":"left","colorForValue":[{"color":"#ff0000","value":"false","valueType":"bool"},{"color":"#008000","value":"true","valueType":"bool"}],"allowColorForValueInMessage":false,"shape":"circle","showGlow":true,"name":"LED2","x":570,"y":220,"wires":[]},{"id":"41c06dba6c2f63a0","type":"ui_led","z":"8c2053d55f5883f8","order":3,"group":"9123b13576984e5e","width":3,"height":3,"label":"LED3","labelPlacement":"left","labelAlignment":"left","colorForValue":[{"color":"#ff0000","value":"false","valueType":"bool"},{"color":"#008000","value":"true","valueType":"bool"}],"allowColorForValueInMessage":false,"shape":"circle","showGlow":true,"name":"LED3","x":570,"y":260,"wires":[]},{"id":"be68d0fe89c4fb10","type":"ui_led","z":"8c2053d55f5883f8","order":4,"group":"9123b13576984e5e","width":3,"height":3,"label":"LED4","labelPlacement":"left","labelAlignment":"left","colorForValue":[{"color":"#ff0000","value":"false","valueType":"bool"},{"color":"#008000","value":"true","valueType":"bool"}],"allowColorForValueInMessage":false,"shape":"circle","showGlow":true,"name":"LED4","x":570,"y":300,"wires":[]},{"id":"3f077821c764e91d","type":"debug","z":"8c2053d55f5883f8","name":"debug 372","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"false","statusVal":"","statusType":"auto","x":770,"y":20,"wires":[]},{"id":"5df891b22fcf0d78","type":"ui_text","z":"8c2053d55f5883f8","group":"9123b13576984e5e","order":5,"width":0,"height":0,"name":"","label":"LED狀態","format":"{{msg.payload}}","layout":"row-left","className":"","style":false,"font":"","fontSize":16,"color":"#000000","x":340,"y":320,"wires":[]},{"id":"fa7ececa4e716634","type":"mqtt in","z":"8c2053d55f5883f8","name":"alex9ufo/temphumi","topic":"alex9ufo/temphumi","qos":"1","datatype":"auto-detect","broker":"192c2b20bef1e71a","nl":false,"rap":true,"rh":0,"inputs":0,"x":110,"y":560,"wires":[["3a41c8a502d904a4","bdfcb0188f2a45d1","093cc1f43bb2193f"]]},{"id":"3a41c8a502d904a4","type":"template","z":"8c2053d55f5883f8","name":"","field":"payload","fieldType":"msg","format":"handlebars","syntax":"mustache","template":"{\"chatId\": 7965218469,\n\"type\":\"message\",\n\"content\":\"{{payload}}\"}","output":"json","x":530,"y":560,"wires":[["6aa0d1e45462c3ef"]]},{"id":"6aa0d1e45462c3ef","type":"telegram sender","z":"8c2053d55f5883f8","name":"","bot":"457874f8aa8a857a","haserroroutput":false,"outputs":1,"x":710,"y":560,"wires":[[]]},{"id":"d20c7a1c503d7b74","type":"debug","z":"8c2053d55f5883f8","name":"debug 373","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"false","statusVal":"","statusType":"auto","x":330,"y":200,"wires":[]},{"id":"bdfcb0188f2a45d1","type":"function","z":"8c2053d55f5883f8","name":" 分離 溫度 濕度","func":"// 取得原始字串,例如 \"Temperature: 5.00C, Humidity: 88.50%\"\nvar input = msg.payload.toString();\n\n// 使用正規表示法抓取數值\n// ([-+]?\\d*\\.?\\d+) 負責抓取包含正負號、整數與小數點的數字\nvar tempMatch = input.match(/Temperature:\\s*([-+]?\\d*\\.?\\d+)/);\nvar humiMatch = input.match(/Humidity:\\s*(\\d*\\.?\\d+)/);\n\nvar msgTemp = null;\nvar msgHumi = null;\n\n// 處理溫度 (輸出端 1)\nif (tempMatch) {\n    var temp = parseFloat(tempMatch[1]);\n    // 檢查是否在指定範圍內 -40 到 +80\n    if (temp >= -40 && temp <= 80) {\n        msgTemp = { \n            payload: temp, \n            unit: \"C\",\n            topic: \"sensor/temperature\" \n        };\n    } else {\n        node.warn(\"溫度數值異常: \" + temp);\n    }\n}\n\n// 處理濕度 (輸出端 2)\nif (humiMatch) {\n    var humi = parseFloat(humiMatch[1]);\n    msgHumi = { \n        payload: humi, \n        unit: \"%\",\n        topic: \"sensor/humidity\" \n    };\n}\n\n// 依照 [輸出 1, 輸出 2] 的順序回傳\nreturn [msgTemp, msgHumi];","outputs":2,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":320,"y":760,"wires":[["253c856683bddece","fef1f76b83d92299"],["f8e06b4a74159f3b","4ec634057e02a3b6"]]},{"id":"253c856683bddece","type":"ui_text","z":"8c2053d55f5883f8","group":"873c37d1e2856b34","order":1,"width":3,"height":1,"name":"","label":"溫度:","format":"{{msg.payload}}℃","layout":"row-left","className":"","style":false,"font":"","fontSize":16,"color":"#000000","x":530,"y":720,"wires":[]},{"id":"f8e06b4a74159f3b","type":"ui_text","z":"8c2053d55f5883f8","group":"873c37d1e2856b34","order":2,"width":3,"height":1,"name":"","label":"濕度","format":"{{msg.payload}} %","layout":"row-left","className":"","style":false,"font":"","fontSize":16,"color":"#000000","x":530,"y":800,"wires":[]},{"id":"fef1f76b83d92299","type":"ui_gauge","z":"8c2053d55f5883f8","name":"","group":"873c37d1e2856b34","order":3,"width":6,"height":3,"gtype":"compass","title":"溫度","label":"℃","format":"{{value}}","min":"-40","max":"80","colors":["#00b500","#e6e600","#ca3838"],"seg1":"20","seg2":"40","diff":false,"className":"","x":530,"y":680,"wires":[]},{"id":"4ec634057e02a3b6","type":"ui_gauge","z":"8c2053d55f5883f8","name":"","group":"873c37d1e2856b34","order":4,"width":6,"height":3,"gtype":"gage","title":"濕度:","label":"%","format":"{{value}}","min":0,"max":"100","colors":["#00b500","#e6e600","#ca3838"],"seg1":"45","seg2":"60","diff":false,"className":"","x":530,"y":840,"wires":[]},{"id":"b4f570bf787abca2","type":"function","z":"8c2053d55f5883f8","name":"(溫度 > 30°C 與 > 70°C) ","func":"// 1. 取得原始字串:Temperature: 75.00C, Humidity: 88.50%\nvar input = msg.payload;\nvar message = \"\";\n\n// 2. 使用正規表示法抓取溫度數值\nvar tempMatch = input.match(/Temperature: ([\\d.]+)/);\nvar humiMatch = input.match(/Humidity: ([\\d.]+)/);\n\nif (tempMatch) {\n    var temp1 = parseFloat(tempMatch[1]); // 將字串轉為數字 75.00\n   \n    // 3. 判斷溫度門檻 (先判斷最高的,避免重複觸發)\n    if (temp1 > 70) {\n        message = \"🔥 【緊急火警警報】偵測到極高溫!\\n目前溫度:\" + temp1 + \"°C\\n請立刻採取行動!\";\n    }\n    else if (temp1 > 30) {\n        message = \"⚠️ 【高溫提醒】環境溫度過高。\\n目前溫度:\" + temp1 + \"°C\\n請注意散熱。\";\n    }\n}\nif (humiMatch) {\n    var temp2 = parseFloat(humiMatch[1]); // 將字串轉為數字 75.00\n\n    // 3. 判斷溫度門檻 (先判斷最高的,避免重複觸發)\n    if (temp2 > 80) {\n        message = \"🌧️ 【緊急警報】偵測到濕度不正常!\\n目前濕度:\" + temp2 + \"%\\n請立刻採取行動!\";\n    }\n    else if (temp2 <10) {\n        message = \"🌧️ 【緊急警報】偵測到濕度不正常。\\n目前濕度:\" + temp2 + \"%\\n請注意散熱。\";\n    }\n}\n\nmsg.payload=message;\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":500,"y":620,"wires":[["3a41c8a502d904a4","24a5e9920dbfad71"]]},{"id":"093cc1f43bb2193f","type":"delay","z":"8c2053d55f5883f8","name":"","pauseType":"delay","timeout":"2","timeoutUnits":"seconds","rate":"1","nbRateUnits":"1","rateUnits":"second","randomFirst":"1","randomLast":"5","randomUnits":"seconds","drop":false,"allowrate":false,"outputs":1,"x":300,"y":620,"wires":[["b4f570bf787abca2"]]},{"id":"24a5e9920dbfad71","type":"play audio","z":"8c2053d55f5883f8","name":"","voice":"","x":710,"y":640,"wires":[]},{"id":"d5ee749083c5c5a2","type":"telegram sender","z":"8c2053d55f5883f8","name":"","bot":"457874f8aa8a857a","haserroroutput":false,"outputs":1,"x":530,"y":500,"wires":[[]]},{"id":"86f6b5808a905449","type":"template","z":"8c2053d55f5883f8","name":"Telegram 歡迎詞","field":"payload","fieldType":"msg","format":"handlebars","syntax":"plain","template":"{\"chatId\": 7965218469,\n\"type\":\"message\",\n\"content\":\"歡迎 Alex 使用 WOKWI&Node-red+Telegram控制系統\\n/1on  : 開啟 LED1\\n/1off : 關閉 LED1\\n/2on  : 開啟 LED2\\n/2off : 關閉 LED2\\n/3on  : 開啟 LED3\\n/3off : 關閉 LED3\\n/4on  : 開啟 LED4\\n/4off : 關閉 LED4\\n/allon  : 開啟 LED1-LED4\\n/alloff : 關閉 LED1-LED4\\n/WOKWI 的 DHT22 輸出 採用 手動方式 要按enter\\n\"}","output":"json","x":300,"y":500,"wires":[["d5ee749083c5c5a2"]]},{"id":"47e254e9be34dfbc","type":"inject","z":"8c2053d55f5883f8","name":"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":true,"onceDelay":0.1,"topic":"","payload":"","payloadType":"date","x":120,"y":500,"wires":[["86f6b5808a905449"]]},{"id":"85fff350d422330f","type":"debug","z":"8c2053d55f5883f8","name":"debug 375","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"false","statusVal":"","statusType":"auto","x":470,"y":80,"wires":[]},{"id":"9a49877e65ede3d6","type":"play audio","z":"8c2053d55f5883f8","name":"","voice":"","x":330,"y":160,"wires":[]},{"id":"457874f8aa8a857a","type":"telegram bot","botname":"@alextest999_bot","usernames":"","chatids":"7965218469","baseapiurl":"","testenvironment":false,"updatemode":"polling","pollinterval":"300","usesocks":false,"sockshost":"","socksprotocol":"socks5","socksport":"6667","socksusername":"anonymous","sockspassword":"","bothost":"","botpath":"","localbothost":"0.0.0.0","localbotport":"8443","publicbotport":"8443","privatekey":"","certificate":"","useselfsignedcertificate":false,"sslterminated":false,"verboselogging":false},{"id":"192c2b20bef1e71a","type":"mqtt-broker","name":"mqttgo","broker":"mqttgo.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":"9123b13576984e5e","type":"ui_group","name":"LED","tab":"ed8b3742f5900395","order":2,"disp":true,"width":"6","collapse":false,"className":""},{"id":"873c37d1e2856b34","type":"ui_group","name":"DHT22","tab":"ed8b3742f5900395","order":1,"disp":true,"width":"6","collapse":false,"className":""},{"id":"ed8b3742f5900395","type":"ui_tab","name":"2026_09_EX4","icon":"dashboard","disabled":false,"hidden":false}]




它專門負責接收來自 Telegram 的文字指令,並將其轉換成 MQTT 訊息發送給 Wokwi 裡的 ESP32。

以下是該流程(Flow)的詳細解析:


程式節點解析

1. 指令接收端 (Telegram Receiver)

  • 節點名稱telegram receiver

  • 功能:監聽您的 Telegram 機器人(@alextest999_bot)。當您在手機輸入任何文字時,這個節點會抓取訊息物件。

  • 關鍵屬性filterCommands: true。這代表它會特別留意以 / 開頭的指令。

2. 資料預處理 (function 節點 - eb8234cf9675d55a)

  • 功能:提取純文字內容。

  • 邏輯:Telegram 傳來的原始資料是一個複雜的物件(包含使用者名稱、時間、ID等),這段代碼 var content = msg.payload.content; 只取出您輸入的指令文字(例如 /1on),並將其重新存入 msg.payload 中,方便後續判斷。


if (msg.payload === "/1on") {
    msg.topic = "alex9ufo/ledcontrol";
    msg.payload = "1on";  // 開啟 LED1
    return [msg , null];
} else if (msg.payload === "/1off") {
    msg.topic = "alex9ufo/ledcontrol";
    msg.payload = "1off"; // 關閉 LED1
    return [msg , null];
} else if (msg.payload === "/2on") {
    msg.topic = "alex9ufo/ledcontrol";
    msg.payload = "2on"; // 開啟 LED2
    return [msg , null];
} else if (msg.payload === "/2off") {
    msg.topic = "alex9ufo/ledcontrol";
    msg.payload = "2off"; // 關閉 LED2
    return [msg , null];
} else if (msg.payload === "/3on") {
    msg.topic = "alex9ufo/ledcontrol";
    msg.payload = "3on"; // 開啟 LED3
    return [msg , null];
} else if (msg.payload === "/3off") {
    msg.topic = "alex9ufo/ledcontrol";
    msg.payload = "3off"; // 關閉 LED3
    return [msg , null];
} else if (msg.payload === "/4on") {
    msg.topic = "alex9ufo/ledcontrol";
    msg.payload = "4on"; // 開啟 LED4
    return [msg , null];
} else if (msg.payload === "/4off") {
    msg.topic = "alex9ufo/ledcontrol";
    msg.payload = "4off"; // 關閉 LED4
    return [msg , null];
} else if (msg.payload === "/allon") {
    msg.topic = "alex9ufo/ledcontrol";
    msg.payload = "allon"; // 開啟 all LED1-LED4
    return [msg , null];
} else if (msg.payload === "/alloff") {
    msg.topic = "alex9ufo/ledcontrol";
    msg.payload = "alloff"; // 關閉 all LED1-LED4
    return [msg , null];
} else if  (msg.payload === "/WOKWI") {
    msg.topic = "alex9ufo/ledcontrol";
    msg.payload = "readData";  // 讀取DHT22溫度濕度
    return [msg, null];
}

//function 根據指令來發佈 MQTT 訊息

3. 指令判斷中心 (function 節點 - 60bd77976d9b25a8)

這是整個流程的大腦,負責**「翻譯」**工作。它將 Telegram 的斜線指令轉換成 ESP32 能理解的簡單字串:

Telegram 指令發佈到 MQTT 的內容 (Payload)動作說明
/1on/4on1on ... 4on開啟指定的 LED 1~4
/1off/4off1off ... 4off關閉指定的 LED 1~4
/allonallon開啟所有 LED
/alloffalloff關閉所有 LED
/WOKWIreadData觸發 ESP32 讀取 DHT22 數據
  • 輸出設計:這個節點設有 2 個輸出端,但目前程式碼主要回傳 [msg, null],代表訊息會從第一個輸出端流向 MQTT。

4. 指令發送端 (MQTT Out)

  • 主題 (Topic)alex9ufo/ledcontrol

  • 功能:將翻譯好的指令(如 readData)送到 mqttgo.io 伺服器。

  • 設定qos: 1 確保指令至少送達一次,retain: true 則是讓之後連線的 ESP32 也能收到最後一次的狀態指令。

程式碼是系統中的**「狀態反饋與顯示中心」**。它的主要工作是監聽來自 ESP32 的狀態回報訊息(LED 到底是開還是關),然後更新 Dashboard 上的燈號,並同步傳送訊息到您的 Telegram 手機上。

這是一個非常完整的雙向溝通循環。以下是逐行與逐節點的詳細說明:


1. 數據入口:MQTT In 節點

  • 節點名稱alex9ufo/ledstatus

  • 功能:這是系統的耳朵。它訂閱了 MQTT Broker (mqttgo.io) 上的主題。

  • 運作:當 Wokwi 的 ESP32 成功切換 LED 後,會發送如 "LED1 ON""ALL LEDs OFF" 的字串,這個節點會第一時間接收到這些文字。


2. 核心邏輯:Function 節點 (c4c0948da43caba8)

這是此流程的大腦,負責將文字訊號轉換為布林值 (True/False) 並分流:

  • 分流設計:它設定了 4 個輸出端,分別對應 4 顆 LED。

  • 邏輯範例

    • 收到 "LED1 ON":將 msg.payload 設為 true,並從 第 1 個 輸出端送出。

    • 收到 "LED1 OFF":將 msg.payload 設為 false,從 第 1 個 輸出端送出。

  • 全開/全關處理

    • 收到 "ALL LEDs ON":會將 true 同時發送到 所有 4 個輸出端,讓介面上的燈一次全亮。

  • 關鍵代碼return [msg, null, null, null] 代表只讓第一路有訊號,其餘保持靜默。

var content = msg.payload;

if (content === 'LED1 ON') {
    msg.payload = true;
    // Route to output 1
    return [msg, null, null, null ];
}
if (content === 'LED1 OFF') {
    msg.payload = false;
    // Route to output 1
    return [msg, null, null, null];
}
if (content === 'LED2 ON') {  
    msg.payload = true;
    // Route to output 2
    return [null, msg, null, null];
}
if (content === 'LED2 OFF') {
    msg.payload = false;
    // Route to output 2
    return [null, msg, null, null];
}
if (content === 'LED3 ON') {  
    msg.payload = true;
    // Route to output 3
    return [null,  null, msg, null];
}
if (content === 'LED3 OFF') {
    msg.payload = false;
    // Route to output 3
    return [null, null, msg, null];
}
if (content === 'LED4 ON') {
    msg.payload = true;
    // Route to output 4
    return [null, null, null, msg];
}
if (content === 'LED4 OFF') {
    msg.payload = false;
    // Route to output 4
    return [null, null, null, msg];
}

if (content === 'ALL LEDs ON') {
    msg.payload = true;
    // Route to output 1-4
    return [msg, msg, msg, msg];
}
if (content === 'ALL LEDs OFF') {
    msg.payload = false;
    // Route to output 1-4
    return [msg, msg, msg, msg];
}



return null;

3. 視覺化顯示:UI LED 節點

  • 包含節點LED1 (7285f33b), LED2, LED3, LED4

  • 功能:在您的 Node-RED Dashboard 網頁上顯示圓形指示燈。

  • 設定

    • 當收到 true:顯示為綠色 (#008000)。

    • 當收到 false:顯示為紅色 (#ff0000)。

  • 群組:全部歸類在名為 LED 的群組,放在 2026_09_EX4 分頁中。


4. Telegram 同步:Template + Telegram Sender

這是讓您的手機同步收到狀態通知的部分:

  • Template 節點 (81ada557b64dc604)

    • 將 MQTT 傳來的純文字(如 "LED1 ON")包裝成 Telegram 專用的 JSON 格式。

    • 設定 chatId: 7965218469,確保訊息傳給您正確的帳號。

  • Telegram Sender 節點 (b7fcc81b89152e02)

    • 負責將包裝好的 JSON 訊息正式發送到您的手機。


5. 輔助功能節點

  • UI Text 節點 (5df891b22fcf0d78):在 Dashboard 上用文字直接顯示「LED1 ON」等字樣,方便閱讀。

  • Play Audio 節點 (9a49877e65ede3d6):這很有趣!每當狀態改變時,您的瀏覽器會發出聲音提醒,這就是您之前想做的「警報聲」基礎。

  • Debug 節點:在側邊欄顯示 Log,方便您檢查 MQTT 連線是否正常。


流程總結與圖解

  1. ESP32 發送 "LED1 ON"

  2. MQTT In 接收訊息。

  3. Function 判斷這是 LED1,將其轉為 true 並從第 1 路輸出。

  4. Dashboard 上的 LED1 燈泡由紅轉綠。

  5. Telegram 同步彈出視窗顯示:「LED1 ON」。



程式碼的功能是系統的**「自動歡迎與說明書發佈器」**。每當 Node-RED 程式啟動或您重新部署(Deploy)時,它會自動發送一則包含完整指令清單的訊息到您的 Telegram 手機上,讓您不需要背誦指令。

以下是針對這四個節點的逐行與運作邏輯說明:


1. 觸發源:Inject 節點 (47e254e9be34dfbc)

  • 關鍵設定once: trueonceDelay: 0.1

  • 功能說明:這是流程的開關。

    • 正常情況下,Inject 節點需要手動點擊才會執行。

    • 但因為您勾選了「啟動後立即執行(Inject once after 0.1 seconds)」,所以每當 Node-RED 伺服器啟動,它就會自動發出一個訊號給下一個節點。


2. 內容產生器:Template 節點 (86f6b5808a905449)

  • 名稱Telegram 歡迎詞

  • 格式JSON 輸出。

  • 功能說明:這是訊息的「樣板」。它將您想看到的文字包裝成 Telegram 節點能讀懂的格式。

  • 內容解析

    • chatId: 7965218469(確保訊息精確傳送到 Alex 的手機)。

    • type: "message"(告知這是一則純文字訊息)。

    • content: 這是最核心的部分,包含了:

      • 歡迎詞歡迎 Alex 使用 WOKWI&Node-red+Telegram控制系統

      • 指令表:列出從 /1on/alloff 的所有可用指令。

      • 提示語:提醒您 Wokwi 端的 DHT22 讀取需要按 Enter。

    • 換行符號:代碼中的 \n 代表在手機顯示時會自動換行,讓排版整齊、易於閱讀。


3. 發送執行端:Telegram Sender 節點 (d5ee749083c5c5a2)

  • 功能說明:這是最後的發射台。

  • 運作邏輯:它接收 Template 節點產生的 JSON 物件,並透過您設定好的機器人帳號(@alextest999_bot),經由網際網路將這份「操作手冊」發送到您的 Telegram App 中。


4. 機器人設定:Telegram Bot 節點 (457874f8aa8a857a)

  • 功能說明:這不是畫布上的實體流程節點,而是背景的配置檔(Configuration Node)。

  • 設定細節

    • 儲存了您的 Bot Token(金鑰)。

    • 設定了 Polling Interval(300ms),代表機器人每 0.3 秒會去 Telegram 伺服器檢查有沒有人傳新訊息。


流程運作圖解

  1. Node-RED 啟動

  2. Inject 節點 倒數 0.1 秒後發出訊號。

  3. Template 節點 準備好「歡迎 Alex...」這串長文字。

  4. Telegram Sender 透過網路將文字推送到您的手機。

  5. Alex 的手機 響起「叮」一聲,完整的指令選單出現在畫面。


Node-RED 程式碼是您系統中的**「數據監測與警報中心」**。它負責接收溫濕度、進行數據解析(拆分數字)、顯示在 Dashboard 儀表板,並在數值異常(過熱或太濕)時觸發 Telegram 與聲音報警。

以下是針對每個區塊的逐行解析:


1. 數據入口:MQTT In 節點 (fa7ececa4e716634)

  • 主題 (Topic)alex9ufo/temphumi

  • 功能:監聽 ESP32 發送的溫濕度字串,格式通常為:Temperature: 25.00C, Humidity: 60.00%


2. 視覺化核心:數據分離 Function (bdfcb0188f2a45d1)

這是負責將原始字串拆解為「純數字」的關鍵節點,設定為 2 個輸出端

  • 正規表示法解析:使用 input.match 抓取 Temperature:Humidity: 後面的數字。

  • 範圍限制:設定溫度必須在 -40°C 到 +80°C 之間才算有效,否則會發出 node.warn 警告。

  • 輸出 1:傳送純溫度數字給 Dashboard 的 溫度計 (Gauge)文字 (Text) 節點。

  • 輸出 2:傳送純濕度數字給 濕度計


3. 警報決策中心:高溫/濕度判斷 Function (b4f570bf787abca2)

這個節點負責檢查數值是否超標:

  • 溫度邏輯

    • > 70°C:產生「🔥 緊急火警警報」文字。

    • > 30°C:產生「⚠️ 高溫提醒」文字。

  • 濕度邏輯

    • > 80% 或 < 10%:產生「🌧️ 濕度不正常」警報文字。

  • 延遲處理 (093cc1f43bb2193f):在判斷前加了 2 秒延遲,避免數據剛進來時太過頻繁觸發警報。


4. 警報發佈端

  • Telegram 同步 (3a41c8a502d904a4):將產生的警告文字封裝成 JSON 格式,並傳送給 Telegram Sender

  • 聲音報警 (24a5e9920dbfad71):當判斷 Function 有訊息輸出時,同步觸發 Play Audio,讓您的電腦喇叭響起(這就是您想要的救護車/警報音效觸發點)。


5. Dashboard 介面元件 (UI)

  • 溫度 Gauge (fef1f76b83d92299):採用 Compass (羅盤型) 顯示溫度,範圍 -40 到 80。

  • 濕度 Gauge (4ec634057e02a3b6):採用標準儀表型,範圍 0 到 100,並用顏色區分(綠、黃、紅)。

  • UI Text 節點:在儀表下方顯示精確的數字與單位。


流程運作圖解

  1. ESP32 發送:Temperature: 75.00C, Humidity: 85.00%

  2. 分離 Function:把數字 75 給溫度計,數字 85 給濕度計。

  3. 判斷 Function

    • 偵測到 75 > 70,生成「火警警報」訊息。

    • 偵測到 85 > 80,生成「濕度異常」訊息。

  4. 執行警報:手機 Telegram 響起通知,電腦喇叭發出警報聲。



Wokwi ESP32 + Node-Red Dashboard UI Template + AngularJS

Wokwi ESP32 +  Node-Red Dashboard UI Template + AngularJS #include < WiFi.h > #include < PubSubClient.h > #include < DHT.h...