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>


沒有留言:

張貼留言

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