一個典型的 IoT(物聯網)與工業自動化系統整合(SCADA/機台連線) 的架構圖。它透過 Node-RED 作為核心資料網關,將前端 ESP32 微控制器 採集的環境數據,經由 MQTT 協定 轉換並寫入工業標準的 Modbus 暫存器 中。
1. 各模組功能拆解
🟢 左上角:WOKWI 虛擬開發平台(前端感測與控制端)
☁️ 右上角:MQTT Broker (mqttgo.io)
🔴 右下角:Node-RED(資料流中樞網關)
🟢 左下角:ModbusSim (Schneider Electric)
功能: 工業通訊協定 Modbus 伺服器/從站(Slave) 的模擬軟體。
畫面解析: 畫面顯示的是 Holding Register(保持暫存器,40001 起始)。
用途: 模擬工廠內部的 PLC(可程式邏輯控制器)或 SCADA(中控監控系統)。Node-RED 將收集到的 IoT 數據(溫濕度、CO_2)寫入這些對應的暫存器編號(如 40001, 40002...)中,讓工業設備可以讀取物聯網端的資料。
2. 整體系統的資料流向(How it works)
這個系統主要實現了雙向通訊:
🔄 數據上傳流程(感測監測)
當 Wokwi 裡的感測器偵測到溫度變化,數據會一路經由雲端與 Node-RED,最後反映在工業 ModbusSim 的表格數字上。
🔄 指令下發流程(設備控制)
💡 總結應用場景
這個架構非常實用,常建構於 「傳統工廠數位轉型」 或 「智慧農業/智慧建築」 中。
例如:工廠原本的舊型中控系統(只懂 Modbus 協定)無法直接連上便宜的 Wi-Fi 感測器。透過這套架構,就能用成本極低的 ESP32 + MQTT 佈署大量環境感測器,並用 Node-RED 完美無縫地將數據餵給工廠原有的 PLC/SCADA 系統 (ModbusSim)。
WOKWI程式
#include <WiFi.h>
#include <PubSubClient.h>
#include <DHT.h>
#include <Wire.h>
#include <LiquidCrystal_I2C.h>
// --- 設定區 ---
const char* ssid = "Wokwi-GUEST"; // Wokwi 專用模擬 WiFi
const char* password = "";
const char* mqtt_server = "mqttgo.io"; // 請替換成你的 MQTT 伺服器網址 (例如 io.adafruit.com)
const int mqtt_port = 1883;
const char* mqtt_user = ""; // 如果需要認證,請填寫
const char* mqtt_pass = ""; // 如果需要認證,請填寫
// --- 引腳定義 ---
#define DHTPIN 15
#define DHTTYPE DHT22
#define LED1_PIN 12
#define LED2_PIN 14
// --- 引腳與控制參數設定 (保留原程式) ---
#define CO2_SENSOR_PIN 36 // MG811 感測器模擬引腳
float co2Level = 0.0;
const float CO2_LOW_THRESHOLD = 400.0; // 最低門檻 (ppm)
const float CO2_HIGH_THRESHOLD = 1000.0; // 最高門檻 (ppm)
// --- MQTT 主題定義 ---
const char* topic_temp = "alex9ufo/temperature";
const char* topic_humi = "alex9ufo/humidity";
const char* topic_ctrl = "alex9ufo/ledcontrol";
const char* topic_status = "alex9ufo/ledstatus";
const char* topic_co2 = "alex9ufo/co2";
// --- 初始化物件 ---
DHT dht(DHTPIN, DHTTYPE);
LiquidCrystal_I2C lcd(0x27, 16, 2); // 模擬器中 I2C 位址通常為 0x27
WiFiClient espClient;
PubSubClient client(espClient);
unsigned long lastMsg = 0;
const long interval = 15000; // 每 5 秒發行一次溫濕度
void setup_wifi() {
delay(10);
Serial.println();
Serial.print("Connecting to ");
Serial.println(ssid);
lcd.clear();
lcd.setCursor(0, 0);
lcd.print("Connecting WiFi");
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println("");
Serial.println("WiFi connected");
lcd.clear();
lcd.setCursor(0, 0);
lcd.print("WiFi Connected");
delay(1000);
}
// 讀取 CO2 感測器 (保留原程式)
float readCO2Sensor() {
int co2Raw = analogRead(CO2_SENSOR_PIN); // 讀取 0~4095 類比值
float co2 = map(co2Raw, 0, 4095, 0, 5000); // 轉換為 0~5000 ppm
Serial.print("CO2 Level: ");
Serial.print(co2);
Serial.println(" ppm");
return co2;
}
// 接收到訂閱訊息的回呼函式 (Callback)
void callback(char* topic, byte* payload, unsigned int length) {
String messageTemp;
Serial.print("Message arrived on topic: ");
Serial.print(topic);
Serial.print(". Message: ");
for (int i = 0; i < length; i++) {
Serial.print((char)payload[i]);
messageTemp += (char)payload[i];
}
Serial.println();
// 顯示接收到的控制指令到 LCD
lcd.clear();
lcd.setCursor(0, 0);
lcd.print("MQTT Recv:");
lcd.setCursor(0, 1);
lcd.print(messageTemp);
// 判斷指令並控制 LED
if (String(topic) == topic_ctrl) {
if (messageTemp == "on1") {
digitalWrite(LED1_PIN, HIGH);
client.publish(topic_status, "ON1");
Serial.println("LED1 turned ON");
} else if (messageTemp == "off1") {
digitalWrite(LED1_PIN, LOW);
client.publish(topic_status, "OFF1");
Serial.println("LED1 turned OFF");
} else if (messageTemp == "on2") {
digitalWrite(LED2_PIN, HIGH);
client.publish(topic_status, "ON2");
Serial.println("LED2 turned ON");
} else if (messageTemp == "off2") {
digitalWrite(LED2_PIN, LOW);
client.publish(topic_status, "OFF2");
Serial.println("LED2 turned OFF");
}
}
delay(1500); // 讓使用者看清 LCD 上的控制訊息,隨後會被溫濕度刷掉
}
void reconnect() {
while (!client.connected()) {
Serial.print("Attempting MQTT connection...");
lcd.clear();
lcd.setCursor(0, 0);
lcd.print("Connect MQTT...");
// 嘗試連接 (使用隨機 Client ID 避免衝突)
String clientId = "ESP32Client-";
clientId += String(random(0xffff), HEX);
// 如果不需要帳密,請改為 client.connect(clientId.c_str())
if (client.connect(clientId.c_str(), mqtt_user, mqtt_pass)) {
Serial.println("connected");
lcd.setCursor(0, 1);
lcd.print("MQTT Connected!");
// 訂閱控制主題
client.subscribe(topic_ctrl);
delay(1000);
} else {
Serial.print("failed, rc=");
Serial.print(client.state());
Serial.println(" try again in 5 seconds");
lcd.setCursor(0, 1);
lcd.print("Failed! Retry...");
delay(5000);
}
}
}
void setup() {
Serial.begin(115200);
pinMode(LED1_PIN, OUTPUT);
pinMode(LED2_PIN, OUTPUT);
digitalWrite(LED1_PIN, LOW);
digitalWrite(LED2_PIN, LOW);
// 初始化 LCD
lcd.init();
lcd.backlight();
lcd.setCursor(0, 0);
lcd.print("Initializing...");
// 初始化 DHT22
dht.begin();
setup_wifi();
client.setServer(mqtt_server, mqtt_port);
client.setCallback(callback);
}
void loop() {
if (!client.connected()) {
reconnect();
}
client.loop();
unsigned long now = millis();
if (now - lastMsg > interval) {
lastMsg = now;
// 讀取溫濕度
float h = dht.readHumidity();
float t = dht.readTemperature();
// 檢查讀取是否失敗
if (isnan(h) || isnan(t)) {
Serial.println("Failed to read from DHT sensor!");
lcd.clear();
lcd.setCursor(0, 0);
lcd.print("DHT Read Error");
return;
}
// 顯示於 Serial Monitor
Serial.print("Humidity: ");
Serial.print(h);
Serial.print("% Temperature: ");
Serial.print(t);
Serial.println("°C");
// 顯示於 16x2 LCD
lcd.clear();
lcd.setCursor(0, 0);
lcd.print("Temp: ");
lcd.print(t, 1);
lcd.print((char)223); // 顯示 ° 符號
lcd.print("C");
lcd.setCursor(0, 1);
lcd.print("Humi: ");
lcd.print(h, 1);
lcd.print("%");
// 發行到 MQTT
client.publish(topic_temp, String(t).c_str());
client.publish(topic_humi, String(h).c_str());
// 1. 讀取數值
co2Level = readCO2Sensor();
// 3. 透過 MQTT 上傳資料到網頁工具
String payload = String(co2Level, 1); // 保留一位小數點的字串
client.publish(topic_co2, payload.c_str());
Serial.println("");
Serial.println("Data published to MQTT.");
}
}
Node-Red程式
需安裝元件 node-red-dashboard 與 node-red-contrib-modbus

[{"id":"eeba1a4109c47dd0","type":"debug","z":"3854e08618e4062d","name":"","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","x":710,"y":120,"wires":[]},{"id":"712b48ea58f445ee","type":"inject","z":"3854e08618e4062d","name":"Write16INT","repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"","payloadType":"date","x":120,"y":114,"wires":[["f968f5a9fdeea64c"]]},{"id":"f968f5a9fdeea64c","type":"function","z":"3854e08618e4062d","name":"Write 16001-16bit","func":"\nvar fc=6;\nvar sa=40;\nvar addresses=1;\nvar value=16001;\n\n//msg.slave_ip=\"192.168.1.76\";\nmsg.slave_ip=\"127.0.0.1\";\nmsg.payload={\"value\":value , 'fc': fc, 'unitid': 1, 'address': sa , 'quantity': addresses };\nreturn msg;","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":310,"y":114,"wires":[["a9b69c2422bc9d89"]]},{"id":"a9b69c2422bc9d89","type":"modbus-flex-write","z":"3854e08618e4062d","name":"","showStatusActivities":true,"showErrors":true,"showWarnings":true,"server":"54dde46e.53267c","emptyMsgOnFail":false,"keepMsgProperties":false,"delayOnStart":true,"startDelayTime":"5","x":530,"y":114,"wires":[[],["eeba1a4109c47dd0"]]},{"id":"9d437cea6ff13a7d","type":"comment","z":"3854e08618e4062d","name":"read functionm","info":"read functionm\n function codes 1,2,3 and 4 \n \nFunction Code\tAction\tTable Name\n01\tRead\tDiscrete Output Coils\n02\tRead\tDiscrete Input Contacts\n04\tRead\tAnalogue Input Registers\n03\tRead\tAnalogue Output Holding Registers\n\n\nCoil/Register Numbers\tType\tTable Name\tData Addresses\tData Type\n\n1-9999\tRead-Write\tDiscrete Output Coils\t0000 to 270E\t**Boolean**\n10001-19999\tRead-Only\tDiscrete Input Coils\t0000 to 270E\t**Boolean**\n\n30001-39999\tRead-Only\tAnalogue Input Registers\t0000 to 270E\t**Unsigned Word**\n\n40001-49999\tRead-Write\tAnalogue Output Holding Registers\t0000 to 270E\t**Unsigned Word**","x":120,"y":440,"wires":[]},{"id":"3bab0340f204cdfa","type":"comment","z":"3854e08618e4062d","name":"write functionm","info":"read functionm\n function codes 5,15,6 and 16 \n \n \nFunction Code\t Action \tTable Name\n05\tWrite single\tDiscrete Output Coil\n15\tWrite multiple\tDiscrete Output Coils\n06\tWrite single\tAnalogue Output Holding Register\n16\tWrite multiple\tAnalogue Output Holding Registers\n\n\n\nCoil/Register Numbers\tType\tTable Name\tData Addresses\tData Type\n\n1-9999\tRead-Write\tDiscrete Output Coils\t0000 to 270E\t**Boolean**\n10001-19999\tRead-Only\tDiscrete Input Coils\t0000 to 270E\t**Boolean**\n\n30001-39999\tRead-Only\tAnalogue Input Registers\t0000 to 270E\t**Unsigned Word**\n\n40001-49999\tRead-Write\tAnalogue Output Holding Registers\t0000 to 270E\t**Unsigned Word**","x":320,"y":440,"wires":[]},{"id":"9988ae212ca63c3d","type":"comment","z":"3854e08618e4062d","name":"映射地址","info":"\n## 代码 中文名称 寄存器 PLC 地址 位操作/字操作 操作数量\n## 01 读数字量输出 00001-09999 位操作 单个或多个\n## 02 读数字量输入 10001-19999 位操作 单个或多个\n## 03 读模拟量输出 40001-49999 字操作 单个或多个\n## 04 读模拟量输入 30001-39999 字操作 单个或多个\n## \n## 05 写单个数字量输出 00001-09999 位操作 单个\n## 06 写单个模拟量输出 40001-49999 字操作 单个\n## 15 写多个数字量输出 00001-09999 位操作 多个\n## 16 写多个模拟量输出 40001-49999 字操作 多个","x":100,"y":500,"wires":[]},{"id":"1724373d709b8c9e","type":"comment","z":"3854e08618e4062d","name":"","info":"fc (function code)\n(fc=1 <--> fc 5,15)\nread \n读数字量输出\nfc=1 , 00001-09999\nwrite\n写单个数字量输出\nfc =5 , 00001-09999\n写多个数字量输出\nfc =15 , 00001-09999\n\nread \n读数字量输出\nfc=2 , 10001-19999\nfc=4 ,30001-39999\n\n\n(fc=3 <--> fc 6,16)\nread \n读模拟量输出出\nfc=3 , 40001-49999\nwrite\n写单个模拟量输出\nfc =6 , 40001-49999\nwrite\n写单个模拟量输出\nfc =16 , 40001-49999\n\n\n\n","x":290,"y":500,"wires":[]},{"id":"1cf7908ca29e8867","type":"mqtt in","z":"3854e08618e4062d","name":"溫度","topic":"alex9ufo/temperature","qos":"1","datatype":"auto-detect","broker":"7c89942e45b95822","nl":false,"rap":true,"rh":0,"inputs":0,"x":90,"y":200,"wires":[["a5494109faab0cdc","81bc6099dfb53505"]]},{"id":"c3243ec0055ae25e","type":"mqtt in","z":"3854e08618e4062d","name":"濕度","topic":"alex9ufo/humidity","qos":"1","datatype":"auto-detect","broker":"7c89942e45b95822","nl":false,"rap":true,"rh":0,"inputs":0,"x":90,"y":280,"wires":[["7192d61f8e331ef8","67042cb56acded04"]]},{"id":"41a98a00ae9ce23a","type":"mqtt in","z":"3854e08618e4062d","name":"CO2","topic":"alex9ufo/co2","qos":"1","datatype":"auto-detect","broker":"7c89942e45b95822","nl":false,"rap":true,"rh":0,"inputs":0,"x":90,"y":360,"wires":[["faa0b4816f5b0791","2e4a046038acf654"]]},{"id":"eb3d7642d1babcb7","type":"debug","z":"3854e08618e4062d","name":"","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","x":690,"y":280,"wires":[]},{"id":"7192d61f8e331ef8","type":"function","z":"3854e08618e4062d","name":"Write humidity","func":"var humi_data=msg.payload;\nvar fc=6;\nvar sa=41;\nvar addresses=1;\nvar value=humi_data;\n\n//msg.slave_ip=\"192.168.1.76\";\nmsg.slave_ip=\"127.0.0.1\";\nmsg.payload={\"value\":value , 'fc': fc, 'unitid': 1, 'address': sa , 'quantity': addresses };\nreturn msg;","outputs":1,"timeout":"","noerr":0,"initialize":"","finalize":"","libs":[],"x":280,"y":280,"wires":[["8808c03a1bfe9001"]]},{"id":"8808c03a1bfe9001","type":"modbus-flex-write","z":"3854e08618e4062d","name":"","showStatusActivities":true,"showErrors":true,"showWarnings":true,"server":"54dde46e.53267c","emptyMsgOnFail":false,"keepMsgProperties":false,"delayOnStart":true,"startDelayTime":"5","x":510,"y":280,"wires":[[],["eb3d7642d1babcb7"]]},{"id":"a7892f07875d5ca7","type":"debug","z":"3854e08618e4062d","name":"","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","x":690,"y":360,"wires":[]},{"id":"faa0b4816f5b0791","type":"function","z":"3854e08618e4062d","name":"Write Co2","func":"var co2_data=msg.payload;\nvar fc=6;\nvar sa=42;\nvar addresses=1;\nvar value = co2_data;\n\n//msg.slave_ip=\"192.168.1.76\";\nmsg.slave_ip=\"127.0.0.1\";\nmsg.payload={\"value\":value , 'fc': fc, 'unitid': 1, 'address': sa , 'quantity': addresses };\nreturn msg;","outputs":1,"timeout":"","noerr":0,"initialize":"","finalize":"","libs":[],"x":260,"y":360,"wires":[["20f203b1ad0491df"]]},{"id":"20f203b1ad0491df","type":"modbus-flex-write","z":"3854e08618e4062d","name":"","showStatusActivities":true,"showErrors":true,"showWarnings":true,"server":"54dde46e.53267c","emptyMsgOnFail":false,"keepMsgProperties":false,"delayOnStart":true,"startDelayTime":"5","x":510,"y":360,"wires":[[],["a7892f07875d5ca7"]]},{"id":"7596c2ca8ab0e56d","type":"debug","z":"3854e08618e4062d","name":"","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","x":690,"y":200,"wires":[]},{"id":"a5494109faab0cdc","type":"function","z":"3854e08618e4062d","name":"Write temperature","func":"var temp_data=msg.payload;\nvar fc=6;\nvar sa=40;\nvar addresses=1;\nvar value = temp_data;\n\n//msg.slave_ip=\"192.168.1.76\";\nmsg.slave_ip=\"127.0.0.1\";\nmsg.payload={\"value\":value , 'fc': fc, 'unitid': 1, 'address': sa , 'quantity': addresses };\nreturn msg;","outputs":1,"timeout":"","noerr":0,"initialize":"","finalize":"","libs":[],"x":290,"y":200,"wires":[["0a5265c317478cd5"]]},{"id":"0a5265c317478cd5","type":"modbus-flex-write","z":"3854e08618e4062d","name":"","showStatusActivities":true,"showErrors":true,"showWarnings":true,"server":"54dde46e.53267c","emptyMsgOnFail":false,"keepMsgProperties":false,"delayOnStart":true,"startDelayTime":"5","x":510,"y":200,"wires":[[],["7596c2ca8ab0e56d"]]},{"id":"81bc6099dfb53505","type":"debug","z":"3854e08618e4062d","name":"debug 1","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"false","statusVal":"","statusType":"auto","x":260,"y":160,"wires":[]},{"id":"67042cb56acded04","type":"debug","z":"3854e08618e4062d","name":"debug 2","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"false","statusVal":"","statusType":"auto","x":260,"y":240,"wires":[]},{"id":"2e4a046038acf654","type":"debug","z":"3854e08618e4062d","name":"debug 3","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"false","statusVal":"","statusType":"auto","x":260,"y":320,"wires":[]},{"id":"54dde46e.53267c","type":"modbus-client","name":"local","clienttype":"tcp","bufferCommands":true,"stateLogEnabled":true,"queueLogEnabled":false,"failureLogEnabled":false,"tcpHost":"127.0.0.1","tcpPort":"502","tcpType":"TCP-RTU-BUFFERED","serialPort":"/dev/ttyUSB","serialType":"RTU-BUFFERD","serialBaudrate":"9600","serialDatabits":"8","serialStopbits":"1","serialParity":"none","serialConnectionDelay":"100","serialAsciiResponseStartDelimiter":"","unit_id":1,"commandDelay":1,"clientTimeout":1000,"reconnectOnTimeout":false,"reconnectTimeout":2000,"parallelUnitIdsAllowed":false,"showErrors":false,"showWarnings":true,"showLogs":true},{"id":"7c89942e45b95822","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":"5220d8a44ad77821","type":"global-config","env":[],"modules":{"node-red-contrib-modbus":"5.45.2"}}]