2026年7月8日 星期三

MQTT 協定與 Modbus 通訊的遠端監控與控制系統架構

MQTT 協定與 Modbus 通訊的遠端監控與控制系統架構


這張圖片展示了一個結合 MQTT 協定與 Modbus 通訊的遠端監控與控制系統架構(主要透過 Node-RED 進行資料整合)。

系統包含三個核心部分,其運作功能說明如下:

1. ESP32 終端設備(硬體控制層)

  • 硬體端: 圖片右上角是一塊 ESP32 開發板,連接到麵包板上的 LED 燈(或透過 IC 擴充的燈號輸出)。

  • MQTT 訂閱與發布:

    • 接收控制: ESP32 訂閱(Subscribe)了主題 alex9ufo/ledcontrol。當收到控制指令時,硬體會據此切換 LED 的開關狀態。

    • 狀態回報: 當 LED 狀態改變或需要定時回報時,ESP32 會將當前狀態發布(Publish)到主題 alex9ufo/ledstatus

2. MQTT Broker(訊息轉發中心)

  • 功能: 位於圖片左上角,扮演中間人角色(Broker)。負責接收來自 Node-RED 或 ESP32 的訊息,並根據定義好的主題(Topic)即時轉發給訂閱該主題的設備,實現硬體端與後端系統的解耦與非同步通訊。

3. Node-RED 與 Modbus 模擬器(核心邏輯與工業通訊整合)

  • Node-RED 整合中心:

    • 透過安裝 node-red-contrib-modbus 套件,Node-RED 同時與 MQTT Broker 和 Modbus 設備進行資料交換。

    • 控制流程: Node-RED 收到來自 Modbus 端的寫入指令後,會將其轉換並發布至 alex9ufo/ledcontrol 來控制 ESP32;反之,它也會訂閱 alex9ufo/ledstatus 來接收 ESP32 的狀態。

  • ModbusSim(Modbus RTU/TCP 模擬器):

    • 模擬工業自動化中常見的 PLC 或控制器設備。

    • 它與 Node-RED 之間透過本機 TCP 網路(127.0.0.1:502)進行連線。

    • Modbus coils Read / Write: Node-RED 會去讀取(Read)或寫入(Write)ModbusSim 中的線圈(Coils)或暫存器狀態,從而讓傳統的工業 Modbus 設備能夠與現代的 MQTT 物聯網架構(ESP32)進行雙向連通。

總結運作情境

這套架構的功能主要是將工業級的 Modbus 協定與物聯網的 MQTT 協定進行橋接。例如:你可以透過操作 ModbusSim 模擬器(模擬中控室的 PLC 燈號開關),讓 Node-RED 偵測到變化後透過 MQTT 去點亮遠端的 ESP32 LED 燈;同理,遠端 ESP32 的狀態也可以即時同步回傳,寫入 ModbusSim 的暫存器中顯示。




WOKWI程式

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

// 1. Wi-Fi 與 MQTT 設定 (Wokwi 專用模擬 Wi-Fi)
const char* ssid = "Wokwi-GUEST";
const char* password = "";
const char* mqtt_server = "mqttgo.io";
const int mqtt_port = 1883;

// MQTT 主題定義
const char* topic_control = "alex9ufo/ledcontrol";
const char* topic_status = "alex9ufo/ledstatus";

// 2. 定義 8 個 LED 的 GPIO 腳位
const int ledPins[8] = { 25, 26, 27, 2 ,4, 5, 18, 19};

WiFiClient espClient;
PubSubClient client(espClient);

// Wi-Fi 連線函式
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.print("IP address: ");
  Serial.println(WiFi.localIP());
}

// 3. 核心:接收 MQTT 訊息的 Callback 函式
void callback(char* topic, byte* payload, unsigned int length) {
  // 將接收到的 payload 轉換為 C++ String 格式
  String message = "";
  for (int i = 0; i < length; i++) {
    message += (char)payload[i];
  }
 
  Serial.print("Message arrived on topic [");
  Serial.print(topic);
  Serial.print("]: ");
  Serial.println(message);

  // 檢查訊息是否以 "led" 開頭,且長度足夠 (例如 led1on 最短 6 字元)
  if (message.startsWith("led") && message.length() >= 6) {
   
    // 取得 LED 編號 (索引從第 3 個字元開始,到找到 "on" 或 "off" 之前)
    int stateIdx = -1;
    if (message.indexOf("on") != -1) stateIdx = message.indexOf("on");
    else if (message.indexOf("off") != -1) stateIdx = message.indexOf("off");

    if (stateIdx != -1) {
      // 擷取中間的數字字串並轉為整數
      int ledNum = message.substring(3, stateIdx).toInt();
      String action = message.substring(stateIdx); // "on" 或 "off"

      // 檢查編號是否在 1 到 8 之間
      if (ledNum >= 1 && ledNum <= 8) {
        int pinIdx = ledNum - 1; // 陣列索引從 0 開始
        String statusPayload = "";

        if (action == "on") {
          digitalWrite(ledPins[pinIdx], HIGH); // 點亮 LED
          statusPayload = "LED" + String(ledNum) + "ON";
        } else if (action == "off") {
          digitalWrite(ledPins[pinIdx], LOW);  // 熄滅 LED
          statusPayload = "LED" + String(ledNum) + "OFF";
        }

        // 4. 即時回報狀態到 alex9ufo/ledstatus
        if (statusPayload != "") {
          client.publish(topic_status, statusPayload.c_str());
          Serial.print("Published status: ");
          Serial.println(statusPayload);
        }
      }
    }
  }
}

// MQTT 重新連線函式
void reconnect() {
  while (!client.connected()) {
    Serial.print("Attempting MQTT connection...");
    // 隨機產生 Client ID 避免重複衝突
    String clientId = "ESP32Client-";
    clientId += String(random(0xffff), HEX);
   
    if (client.connect(clientId.c_str())) {
      Serial.println("connected");
      // 連線成功後重新訂閱控制主題
      client.subscribe(topic_control);
      Serial.print("Subscribed to: ");
      Serial.println(topic_control);
    } else {
      Serial.print("failed, rc=");
      Serial.print(client.state());
      Serial.println(" try again in 5 seconds");
      delay(5000);
    }
  }
}

void setup() {
  Serial.begin(115200);
 
  // 初始化 8 個 LED 腳位為輸出模式,預設關閉 (LOW)
  for (int i = 0; i < 8; i++) {
    pinMode(ledPins[i], OUTPUT);
    digitalWrite(ledPins[i], LOW);
  }

  setup_wifi();
  client.setServer(mqtt_server, mqtt_port);
  client.setCallback(callback); // 設定收到訊息時的處理函式
}

void loop() {
  if (!client.connected()) {
    reconnect();
  }
  client.loop(); // 維持 MQTT 連線與處理底層通訊
}


Data AddressesReadWrite SingleWrite Multiple
Discrete Output Coils 0xxxxFC01FC05FC15








const coils = msg.payload;
let messagesToPublish = [];

for (let i = 0; i < 8; i++) {
    let ledNumber = i + 1;
    let stateStr = (coils[i] === true) ? "on" : "off";
    let customPayload = "led" + ledNumber + stateStr;
   
    messagesToPublish.push({
        topic: "alex9ufo/ledcontrol",
        payload: customPayload
    });
}
return [messagesToPublish];


// 確保進來的是陣列
if (!Array.isArray(msg.payload)) {
    return null;
}

const rawArray = msg.payload;
let resultArray = [false, false, false, false, false, false, false, false];

rawArray.forEach(str => {
    if (typeof str === 'string' && str.startsWith("LED")) {
        let stateIdx = -1;
        if (str.indexOf("ON") != -1) stateIdx = str.indexOf("ON");
        else if (str.indexOf("OFF") != -1) stateIdx = str.indexOf("OFF");
       
        if (stateIdx != -1) {
            let ledNum = parseInt(str.substring(3, stateIdx));
            let action = str.substring(stateIdx);
           
            if (ledNum >= 1 && ledNum <= 8) {
                let arrayIndex = ledNum - 1;
                resultArray[arrayIndex] = (action === "ON");
            }
        }
    }
});

msg.payload = resultArray;
return msg;



Node-Red程式

[{"id":"de273b1b8bd36b17","type":"modbus-read","z":"56c70b10b2e55b6e","name":"","topic":"","showStatusActivities":false,"logIOActivities":false,"showErrors":false,"showWarnings":true,"unitid":"1","dataType":"Coil","adr":"0","quantity":"8","rate":"10000","rateUnit":"ms","delayOnStart":false,"enableDeformedMessages":true,"startDelayTime":"","server":"54dde46e.53267c","useIOFile":false,"ioFile":"","useIOForPayload":false,"emptyMsgOnFail":false,"x":150,"y":120,"wires":[[],["eb0999703abe22e8"]]},{"id":"eb0999703abe22e8","type":"function","z":"56c70b10b2e55b6e","name":"function  ","func":"var data=msg.payload.data;\nmsg.payload=data;\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":300,"y":120,"wires":[["led_single_topic_fn"]]},{"id":"cceab653f17ba013","type":"function","z":"56c70b10b2e55b6e","name":"Read FC=15","func":"// Define your coil array values\nconst coilValues = msg.payload;\n\nmsg.payload = {\n    \"value\": coilValues,        // Array of booleans to write\n    \"fc\": 15,                   // Function Code 15 (Write Multiple Coils)\n    \"unitid\": 1,                // Modbus Slave/Unit ID\n    \"address\": 16,               // Starting coil memory address\n    \"quantity\": coilValues.length // Number of coils to write\n};\n\nreturn msg;\n","outputs":1,"timeout":"","noerr":0,"initialize":"","finalize":"","libs":[],"x":450,"y":320,"wires":[["baf9113b483f99d0","d135dcbf561bc6db"]]},{"id":"f237104758129409","type":"debug","z":"56c70b10b2e55b6e","name":"debug 5","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"false","statusVal":"","statusType":"auto","x":800,"y":340,"wires":[]},{"id":"baf9113b483f99d0","type":"debug","z":"56c70b10b2e55b6e","name":"debug 6","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"false","statusVal":"","statusType":"auto","x":620,"y":280,"wires":[]},{"id":"d135dcbf561bc6db","type":"modbus-write","z":"56c70b10b2e55b6e","name":"","showStatusActivities":false,"showErrors":false,"showWarnings":true,"unitid":"1","dataType":"MCoils","adr":"16","quantity":"8","server":"54dde46e.53267c","emptyMsgOnFail":false,"keepMsgProperties":false,"delayOnStart":false,"startDelayTime":"","x":620,"y":340,"wires":[[],["f237104758129409"]]},{"id":"mq_l1","type":"mqtt out","z":"56c70b10b2e55b6e","name":"LED 1-8 MQTT 傳送端 (固定主題)","topic":"alex9ufo/ledcontrol","qos":"1","retain":"false","respTopic":"","contentType":"","userProps":"","correl":"","expiry":"","broker":"7c89942e45b95822","x":780,"y":120,"wires":[]},{"id":"led_single_topic_fn","type":"function","z":"56c70b10b2e55b6e","name":"編碼為單一主題訊息","func":"const coils = msg.payload;\nlet messagesToPublish = [];\n\nfor (let i = 0; i < 8; i++) {\n    let ledNumber = i + 1;\n    let stateStr = (coils[i] === true) ? \"on\" : \"off\";\n    let customPayload = \"led\" + ledNumber + stateStr;\n    \n    messagesToPublish.push({\n        topic: \"alex9ufo/ledcontrol\",\n        payload: customPayload\n    });\n}\n\nreturn [messagesToPublish];","outputs":1,"timeout":"","noerr":0,"initialize":"","finalize":"","libs":[],"x":500,"y":120,"wires":[["mq_l1"]]},{"id":"mqtt_in_status","type":"mqtt in","z":"56c70b10b2e55b6e","name":"訂閱 ledstatus","topic":"alex9ufo/ledstatus","qos":"1","datatype":"auto-detect","broker":"7c89942e45b95822","nl":false,"rap":true,"rh":0,"inputs":0,"x":120,"y":200,"wires":[["join_node_correct"]]},{"id":"join_node_correct","type":"join","z":"56c70b10b2e55b6e","name":"Join 收集8條","mode":"manual","build":"array","property":"payload","joiner":"\\n","joinerType":"str","accumulate":false,"timeout":"50","count":"8","x":330,"y":200,"wires":[["function_convert_correct"]]},{"id":"function_convert_correct","type":"function","z":"56c70b10b2e55b6e","name":"Function 轉換布林陣列","func":"// 確保進來的是陣列\nif (!Array.isArray(msg.payload)) {\n    return null;\n}\n\nconst rawArray = msg.payload;\nlet resultArray = [false, false, false, false, false, false, false, false];\n\nrawArray.forEach(str => {\n    if (typeof str === 'string' && str.startsWith(\"LED\")) {\n        let stateIdx = -1;\n        if (str.indexOf(\"ON\") != -1) stateIdx = str.indexOf(\"ON\");\n        else if (str.indexOf(\"OFF\") != -1) stateIdx = str.indexOf(\"OFF\");\n        \n        if (stateIdx != -1) {\n            let ledNum = parseInt(str.substring(3, stateIdx));\n            let action = str.substring(stateIdx);\n            \n            if (ledNum >= 1 && ledNum <= 8) {\n                let arrayIndex = ledNum - 1;\n                resultArray[arrayIndex] = (action === \"ON\");\n            }\n        }\n    }\n});\n\nmsg.payload = resultArray;\nreturn msg;","outputs":1,"timeout":"","noerr":0,"initialize":"","finalize":"","libs":[],"x":560,"y":200,"wires":[["debug_correct_out","cceab653f17ba013"]]},{"id":"debug_correct_out","type":"debug","z":"56c70b10b2e55b6e","name":"正確輸出布林陣列","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":790,"y":200,"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":"dd649eb72b4bcf6f","type":"global-config","env":[],"modules":{"node-red-contrib-modbus":"5.45.2"}}]

2026年7月7日 星期二

Modbus FC=1 (Coils read) & FC=15 (Coils Write)

Modbus FC=1  (Coils read)  & FC=15 (Coils Write)




 「從 Modbus 設備讀取多個線圈(Coils)狀態,並立刻將這些狀態原封不動地轉寫到另一個記憶體位址(起始位址 16)」



整個流程使用了知名套件 node-red-contrib-modbus,主要分為兩個階段:讀取暫存器(Read FC=1)批次寫入暫存器(Write FC=15)

以下為您詳細拆解各個節點的功能與資料流向:

1. 流程圖架構與資料流

這個流程的運作路徑如下:

Modbus Read (讀取線圈) $\rightarrow$ Function (提取資料) $\rightarrow$ Function (打包 FC15 格式) $\rightarrow$ Modbus Write (寫入新位址)

🟢 第一階段:定時讀取線圈狀態 (Modbus Read)

  • 節點名稱: modbus-read

  • 通訊參數:

    • DataType: Coil(代表功能碼 FC=1, Read Coil Status)。

    • Address (adr): 0(從記憶體位址 0 開始讀)。

    • Quantity: 8(連續讀取 8 個線圈的狀態,通常代表 1 個位元組 Byte 的 8 個位元)。

    • Rate: 10000 ms(每 10 秒自動循環讀取一次)。

    • Unit ID: 1(通訊設備站號為 1)。

🟡 第二階段:資料提取與清洗 (Function 節點)

讀取成功後,Modbus Read 節點輸出的是一個複雜的物件(Object),接著交給名稱為 function 的節點處理:

JavaScript
var data = msg.payload.data; // 從回傳物件中提取出真正的線圈陣列(例如 [true, false, true...])
msg.payload = data;          // 將 payload 取代為這個純陣列
return msg;
  • Debug 4: 在這裡接了一個除錯節點,用來在側邊欄監看提取出來的 8 個線圈布林值陣列。

🔵 第三階段:重新打包寫入格式 (Read FC=15 節點)

這是最關鍵的邏輯轉換區。名稱為 Read FC=15 的 Function 節點將剛才讀到的 8 個線圈值,重新打包成符合 modbus-write 節點要求的物件格式:

JavaScript
const coilValues = msg.payload; // 取得剛才的 8 個布林值陣列

msg.payload = {
    "value": coilValues,         // 要寫入的布林值陣列
    "fc": 15,                    // 強制指定功能碼 FC=15 (Write Multiple Coils,強行寫入多個線圈)
    "unitid": 1,                 // 設備站號 1
    "address": 16,               // 變更寫入的目的地起始位址為 16
    "quantity": coilValues.length // 寫入長度(自動取得陣列長度,即 8)
};

return msg;

🟠 第四階段:寫入新記憶體位址 (Modbus Write)

  • 節點名稱: modbus-write

  • 通訊參數:

    • DataType: MCoils(Multiple Coils,複數線圈)。

    • Address (adr): 16(寫入位址 16 ~ 23)。

    • 功能: 接收上一個節點打包好的物件,透過 Modbus TCP 網路,將原本從位址 0~7 讀到的開關狀態,複製寫入到位址 16~23

  • Debug 5 & 6: 用來監控寫入前與寫入後的系統回傳狀態(判斷是否成功寫入)。

2. 底層通訊設定 (Modbus Client)

流程最下方配置了一個名為 local 的通訊客戶端物件(modbus-client):

  • 連線方式: TCP-RTU-BUFFERED(這是一種很常見的架構,代表實體設備是 RS-485 序列埠走 RTU 協定,但前端透過了一個 TCP 轉 RS-485 伺服器/閘道器 連線,或者是在本機透過虛擬串列埠轉 TCP 的方式運作)。

  • IP 與 Port: 連線到本機端 127.0.0.1,通訊埠 502(標準 Modbus TCP Port)。

💡 總結這個程式的目的

這段程式的目的就是「記憶體狀態同步(Data Mirroring/Mapping)」。

在工廠實務上,這通常用於資料橋接。例如:PLC 的位址 0~7 是現場實體按鈕或感測器的輸入狀態(Inputs),而系統需要把這些狀態即時複製到位址 16~23(內部旗標位元 Internal Bits 或輸出暫存器),以便讓工廠的 HMI 人機介面、SCADA 中控軟體,或是您上一張圖提到的 ModbusSim 模擬器 能夠在特定區塊讀取到集中的環境狀態。







Node-red程式

[{"id":"de273b1b8bd36b17","type":"modbus-read","z":"56c70b10b2e55b6e","name":"","topic":"","showStatusActivities":false,"logIOActivities":false,"showErrors":false,"showWarnings":true,"unitid":"1","dataType":"Coil","adr":"0","quantity":"8","rate":"10000","rateUnit":"ms","delayOnStart":false,"enableDeformedMessages":true,"startDelayTime":"","server":"54dde46e.53267c","useIOFile":false,"ioFile":"","useIOForPayload":false,"emptyMsgOnFail":false,"x":190,"y":220,"wires":[[],["eb0999703abe22e8"]]},{"id":"563895aaeb2074e8","type":"debug","z":"56c70b10b2e55b6e","name":"debug 4","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"false","statusVal":"","statusType":"auto","x":560,"y":220,"wires":[]},{"id":"eb0999703abe22e8","type":"function","z":"56c70b10b2e55b6e","name":"function  ","func":"var data=msg.payload.data;\nmsg.payload=data;\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":360,"y":220,"wires":[["563895aaeb2074e8","cceab653f17ba013"]]},{"id":"cceab653f17ba013","type":"function","z":"56c70b10b2e55b6e","name":"Read FC=15","func":"// Define your coil array values\nconst coilValues = msg.payload;\n\nmsg.payload = {\n    \"value\": coilValues,        // Array of booleans to write\n    \"fc\": 15,                   // Function Code 15 (Write Multiple Coils)\n    \"unitid\": 1,                // Modbus Slave/Unit ID\n    \"address\": 16,               // Starting coil memory address\n    \"quantity\": coilValues.length // Number of coils to write\n};\n\nreturn msg;\n","outputs":1,"timeout":"","noerr":0,"initialize":"","finalize":"","libs":[],"x":170,"y":340,"wires":[["baf9113b483f99d0","d135dcbf561bc6db"]]},{"id":"f237104758129409","type":"debug","z":"56c70b10b2e55b6e","name":"debug 5","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"false","statusVal":"","statusType":"auto","x":560,"y":340,"wires":[]},{"id":"baf9113b483f99d0","type":"debug","z":"56c70b10b2e55b6e","name":"debug 6","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"false","statusVal":"","statusType":"auto","x":360,"y":300,"wires":[]},{"id":"d135dcbf561bc6db","type":"modbus-write","z":"56c70b10b2e55b6e","name":"","showStatusActivities":false,"showErrors":false,"showWarnings":true,"unitid":"1","dataType":"MCoils","adr":"16","quantity":"8","server":"54dde46e.53267c","emptyMsgOnFail":false,"keepMsgProperties":false,"delayOnStart":false,"startDelayTime":"","x":380,"y":340,"wires":[[],["f237104758129409"]]},{"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":"007e6f266989a5ed","type":"global-config","env":[],"modules":{"node-red-contrib-modbus":"5.45.2"}}]

2026年7月6日 星期一

ESP32 微控制器 採集的環境數據,經由 MQTT 協定 轉換並寫入工業標準的 Modbus 暫存器 中

 

一個典型的 IoT(物聯網)與工業自動化系統整合(SCADA/機台連線) 的架構圖。它透過 Node-RED 作為核心資料網關,將前端 ESP32 微控制器 採集的環境數據,經由 MQTT 協定 轉換並寫入工業標準的 Modbus 暫存器 中。



1. 各模組功能拆解

🟢 左上角:WOKWI 虛擬開發平台(前端感測與控制端)

  • 核心晶片: 使用 ESP32 微控制器晶片(負責處理資料、Wi-Fi 連線與網頁通訊)。

  • 週邊元件:

    • DHT22 溫濕度感測器: 負責採集環境中的「溫度」與「濕度」數據。

    • LCD 顯示器(如 I2C 1602/2004): 在現地端即時顯示目前的溫濕度及 CO_2 數值。

    • 繼電器模組(Relay)與 LED: 模擬開關控制,用來接收遠端指令以控制設備(如風扇、警報燈等)。

  • 紅色程式碼(MQTT 主題定義): 這裡定義了 ESP32 發布(Publish)與訂閱(Subscribe)的 MQTT Topics:

    • alex9ufo/temperature(傳送溫度)

    • alex9ufo/humidity(傳送濕度)

    • alex9ufo/co2(傳送二氧化碳濃度)

    • alex9ufo/ledcontrol(接收 LED 控制指令)

    • alex9ufo/ledstatus(回傳目前 LED 狀態)

☁️ 右上角:MQTT Broker (mqttgo.io)

  • 功能: 雲端或中介的訊息經紀人(這裡使用的是免費/公開的 mqttgo.io 伺服器)。

  • 角色: 它不儲存資料,而是充當「郵局」。ESP32 把數據丟給它,Node-RED 再從它這裡把數據領走;反之亦然。這種「發布/訂閱(Pub/Sub)」機制非常適合輕量級的 IoT 裝置。

🔴 右下角:Node-RED(資料流中樞網關)

  • 功能: 這是一個流程編排工具,負責扮演 協定轉換器(Protocol Gateway)

  • 流程邏輯:

    1. 透過 MQTT 節點監聽 mqttgo.io 上的主題(如溫濕度)。

    2. 收到數據後,經過內部的函式節點(如圖中的 Write temperatureWrite humidity)進行資料格式轉換。

    3. 最後透過 Modbus Flex Write 節點,將數據轉換為工業用的 Modbus 協定,寫入底下的模擬器中。

🟢 左下角: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"}}]

MQTT 協定與 Modbus 通訊的遠端監控與控制系統架構

MQTT 協定與 Modbus 通訊的遠端監控與控制系統架構 這張圖片展示了一個 結合 MQTT 協定與 Modbus 通訊的遠端監控與控制系統架構 (主要透過 Node-RED 進行資料整合)。 系統包含三個核心部分,其運作功能說明如下: 1. ESP32 終端設備(硬體控制層...