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"}}]

2026年7月5日 星期日

圖形介面(GUI)的即時數據監控儀表( Modbus TCP 客戶端 Python)

圖形介面(GUI)的即時數據監控儀表( Modbus TCP 客戶端 Python)


「打造一個具備圖形介面(GUI)的即時數據監控儀表板」,用來讀取並呈現 Modbus TCP 伺服器(Server)內部的暫存器與開關狀態。

簡單來說,它扮演的是 Modbus TCP 客戶端(Client / Master) 的角色,就像一個數位看門狗,每秒固定去向主機敲門、要資料,然後把冷冰冰的數據轉化為人類看得懂的視覺畫面。

以下為您拆解這段程式的三大核心意義與運作機制:

1. 建立與硬體/模擬器的通訊橋樑 (Modbus TCP)

程式中使用了 pyModbusTCP 套件,這是在工業自動化(如 PLC、感測器、智慧電表)中非常常見的通訊協定。

  • 目標連線:鎖定在本機端(127.0.0.1)的 502 通訊埠(Port),這正是工業上標準的 Modbus TCP 埠號。

  • 自動管理:設定 auto_open=True,代表程式只要發現連線斷開就會自動嘗試重新連線,不需人工介入。

2. 模擬與對齊實體畫面 (UI 排版意義)

利用 tkinter 庫,程式將資料分門別類,精準對應工業控制中常見的兩種資料型態:

  • 左半邊:Holding Registers (FC=03)

    • 意義:用來儲存數值型資料(例如:溫度、壓力、馬達轉速)。

    • 設計:排成 4 列 × 16 行,剛好對應地址 40001 到 40064。程式還貼心地做了 val -= 65536 的負數轉換,這是因為工業設備傳輸負數時常使用「二補數」表示法,如果不轉換,畫面上的 -3 可能會變成 65533

  • 右半邊:Coil Output (FC=01)

    • 意義:用來儲存二進位狀態(布林值)(例如:燈泡開關、警報觸發、閥門開啟或關閉)。

    • 設計:同樣是 64 個地址,程式利用 💡 ON⚫ OFF 的顏色交替,讓操作員一眼就能看出哪個設備正在運行、哪個正在休息。

3. 即時更新機制 (Real-time Monitoring)

這是整個程式的靈魂所在。如果程式只讀取一次,數值就不會變動。

  • 程式在最後使用了 self.root.after(1000, self.update_modbus_data)

  • 它的意義在於每隔 1000 毫秒(1秒),程式就會自動在背景重新執行一次「抓取資料 $\rightarrow$ 更新畫面」的動作。

  • 畫面上方同時設有狀態列。連線成功時顯示綠色的「狀態: 連線正常」;若網路斷線或伺服器關閉,則立刻變紅提示「連線失敗 / 超時」,防止操作員看到錯誤或過期的舊數據。

總結

這段程式的終極目的,就是把底層複雜的二進位通訊封包,轉化為一個直觀、整齊、每秒動態更新的儀表板,讓工程師或系統操作員能舒舒服服地坐在電腦前,監控遠端設備的所有 Holding Registers 與 Coils 狀態。



在工業自動化、物聯網(IoT)以及機電整合的領域中,「TCP 127.0.0.1:502」 是一個非常經典且標準的通訊組合。我們可以把它拆解為三個核心部分來理解:通訊協定(Modbus TCP)網路地址(127.0.0.1) 以及 通訊埠(502)

以下為您詳細說明這個設定在程式與軟體中的具體意義:

1. 什麼是 127.0.0.1?(本機迴環地址)

  • 定義:在電腦網路中,127.0.0.1 被稱為 Loopback Address(迴環地址),通常對應的主機名稱就是 localhost

  • 意義:當程式把連線目標設定為 127.0.0.1 時,代表「這台電腦自己要跟自己通訊」。封包不會透過實體網路線或 Wi-Fi 送到外面去,而是在作業系統內部的網路層直接折返。

  • 應用場景:這在軟體開發與測試階段非常重要。例如您在電腦上開啟了一個 Modbus 模擬軟體(如您第一張圖中的 ICDT 模擬程式)作為伺服器,然後又寫了一個 Python 程式去讀取它。這兩個軟體都跑在同一台電腦上,彼此就是透過 127.0.0.1 進行溝通。

2. 什麼是 Port 502?(Modbus TCP 的標準通訊埠)

  • 定義:通訊埠(Port)就像是通往電腦這棟大樓的「房間號碼」。一台電腦同時可以執行很多網路程式(網頁瀏覽器、LINE、遊戲等),不同的程式必須使用不同的 Port 來區分資料。

  • 意義:根據國際網際網路網路號碼分配局(IANA)的規定,502 是專門保留給 Modbus 通訊協定使用的標準連接埠

  • 程式行為:當您的 Python 程式指定連線到 502 時,作業系統就知道這個資料封包必須交給正在監聽 502 埠的「工業設備軟體」或「PLC 模擬器」,而不會跟其他的網路流量混淆。

3. 程式與軟體架構:Client 與 Server 的互動

當我們在程式中設定 IP=127.0.0.1:502 時,通常包含兩個角色(主從架構 Master/Slave,在 TCP 中稱為 Client/Server):

伺服器端 (Modbus TCP Server / Slave)

  • 角色:資料的擁有者(例如 PLC、智慧電表或您的模擬軟體)。

  • 行為:它會在電腦中「監聽(Listen)」127.0.0.1:502。它的任務是坐在那裡,等待別人來向它要資料,或者等待別人發送指令來改變暫存器數值。

客戶端 (Modbus TCP Client / Master)

  • 角色:資料的請求者(例如我們寫的 Python 程式、SCADA 中控系統或 HMI 人機介面)。

  • 行為:它會主動發起連線,向 127.0.0.1:502 發送請求封包(例如:「請給我 Holding Register 40001 到 40064 的數值」),收到 Server 回傳的資料後,再將其顯示在畫面上。

總結

在您的實際應用中:

  1. 模擬軟體 執行後,在您的電腦上開啟了 502 房間(建立 Server)。

  2. Python 程式 執行後,向自己電腦的 502 房間敲門(建立 Client 連線)。

  3. 兩者在同一台電腦(127.0.0.1)內,透過 Modbus TCP 通訊協定 實現了每秒的即時數據同步。這就是工業軟體開發中最標準的本機測試流程。

程式1

import tkinter as tk

from tkinter import ttk

from pyModbusTCP.client import ModbusClient


class ModbusViewerApp:

    def __init__(self, root):

        self.root = root

        self.root.title("ICDT MODBUS TCP Client 監視器 (64 Address 修正版)")

        self.root.geometry("1300x650")  # 增加寬度以容納第 5 列

        self.root.configure(bg="#f0f0f0")


        # 初始化 Modbus TCP 客戶端 (連線至本機 502 埠)

        self.client = ModbusClient(host="127.0.0.1", port=502, auto_open=True, auto_close=True)


        # 頂部狀態列

        top_frame = tk.Frame(self.root, bg="#e0e0e0", height=40)

        top_frame.pack(fill=tk.X, side=tk.TOP)

        

        lbl_info = tk.Label(top_frame, text="連線設定: IP=127.0.0.1 | Port=502", font=("Arial", 10, "bold"), bg="#e0e0e0")

        lbl_info.pack(side=tk.LEFT, padx=10, pady=5)

        

        self.lbl_status = tk.Label(top_frame, text="狀態: 未連線", fg="red", bg="#e0e0e0", font=("Arial", 10, "bold"))

        self.lbl_status.pack(side=tk.RIGHT, padx=15, pady=5)


        # 主內容框架

        main_frame = tk.Frame(self.root, bg="#f0f0f0")

        main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)


        # --- 左半邊: Holding Registers (FC=03) ---

        hr_frame = tk.LabelFrame(main_frame, text=" FC=03 Holding Register 40001-40064 ", font=("Arial", 11, "bold"), fg="blue", bg="#f0f0f0")

        hr_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=5)


        self.hr_inputs = []  # 儲存 Entry 元件,方便後續更新數值

        

        # 修正:改為 5 列,每列 14 個,可完整顯示到 63 (共 64 個)

        for col in range(5):

            col_subframe = tk.Frame(hr_frame, bg="#f0f0f0")

            col_subframe.pack(side=tk.LEFT, fill=tk.Y, padx=5, pady=5)

            

            for row in range(14):

                index = col * 14 + row

                if index >= 64:  # 超過 63 (第 64 個) 就停止建立

                    break

                

                row_frame = tk.Frame(col_subframe, bg="#f0f0f0")

                row_frame.pack(fill=tk.X, pady=2)

                

                # 顯示索引編號

                lbl_idx = tk.Label(row_frame, text=f"{index:2d}", width=3, anchor="e", bg="#f0f0f0", font=("Courier", 10))

                lbl_idx.pack(side=tk.LEFT, padx=2)

                

                # 數值顯示方塊

                val_entry = ttk.Entry(row_frame, width=7, justify="center")

                val_entry.insert(0, "0")

                val_entry.configure(state="readonly")

                val_entry.pack(side=tk.LEFT, padx=2)

                

                self.hr_inputs.append(val_entry)


        # --- 右半邊: Coil Output (FC=01) ---

        coil_frame = tk.LabelFrame(main_frame, text=" FC=01 Coil output 00001-00064 ", font=("Arial", 11, "bold"), fg="red", bg="#f0f0f0")

        coil_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True, padx=5)


        self.coil_bulbs = []  # 儲存燈號 Label 元件

        

        # 修正:改為 5 列,每列 14 個,可完整顯示到 63 (共 64 個)

        for col in range(5):

            col_subframe = tk.Frame(coil_frame, bg="#f0f0f0")

            col_subframe.pack(side=tk.LEFT, fill=tk.Y, padx=10, pady=5)

            

            for row in range(14):

                index = col * 14 + row

                if index >= 64:  # 超過 63 (第 64 個) 就停止建立

                    break

                

                row_frame = tk.Frame(col_subframe, bg="#f0f0f0")

                row_frame.pack(fill=tk.X, pady=4)

                

                # 顯示索引編號

                lbl_idx = tk.Label(row_frame, text=f"{index:2d}", width=3, anchor="e", bg="#f0f0f0", font=("Courier", 10))

                lbl_idx.pack(side=tk.LEFT, padx=2)

                

                # 用文字與顏色模擬燈泡 (💡 表示 ON,⚫ 表示 OFF)

                lbl_bulb = tk.Label(row_frame, text="⚫ OFF", width=6, fg="gray", bg="#f0f0f0", font=("Arial", 10, "bold"), anchor="w")

                lbl_bulb.pack(side=tk.LEFT, padx=3)

                

                self.coil_bulbs.append(lbl_bulb)


        # 開始定時更新迴圈

        self.update_modbus_data()


    def update_modbus_data(self):

        # 1. 讀取 Holding Registers (從位址 0 開始讀 64 個,對應 40001-40064)

        regs_data = self.client.read_holding_registers(0, 64)

        

        # 2. 讀取 Coils (從位址 0 開始讀 64 個,對應 00001-00064)

        coils_data = self.client.read_coils(0, 64)


        if regs_data and coils_data:

            self.lbl_status.configure(text="狀態: 連線正常", fg="green")

            

            # 更新 Holding Registers 畫面

            for idx, val in enumerate(regs_data):

                if idx < len(self.hr_inputs):

                    # 處理 16-bit 有號整數負數顯示

                    if val > 32767: 

                        val -= 65536

                    

                    self.hr_inputs[idx].configure(state="normal")

                    self.hr_inputs[idx].delete(0, tk.END)

                    self.hr_inputs[idx].insert(0, str(val))

                    self.hr_inputs[idx].configure(state="readonly")

            

            # 更新 Coils 燈號畫面

            for idx, status in enumerate(coils_data):

                if idx < len(self.coil_bulbs):

                    if status:

                        self.coil_bulbs[idx].configure(text="💡 ON", fg="#FF9900") # 橘黃色亮燈

                    else:

                        self.coil_bulbs[idx].configure(text="⚫ OFF", fg="gray")   # 灰色滅燈

        else:

            self.lbl_status.configure(text="狀態: 連線失敗 / 超時", fg="red")


        # 每 1000 毫秒 (1秒) 重新呼叫自己一次

        self.root.after(1000, self.update_modbus_data)


if __name__ == "__main__":

    root = tk.Tk()

    app = ModbusViewerApp(root)

    root.mainloop()

程式2


import tkinter as tk
from tkinter import ttk
from pyModbusTCP.client import ModbusClient

class ModbusViewerApp:
    def __init__(self, root):
        self.root = root
        self.root.title("ICDT MODBUS TCP Client 監視器 (每列16個-共64個Address)")
        self.root.geometry("1200x680")  # 微調視窗大小以符合 16 列的高度與寬度
        self.root.configure(bg="#f0f0f0")

        # 初始化 Modbus TCP 客戶端 (連線至本機 502 埠)
        self.client = ModbusClient(host="127.0.0.1", port=502, auto_open=True, auto_close=True)

        # 頂部狀態列
        top_frame = tk.Frame(self.root, bg="#e0e0e0", height=40)
        top_frame.pack(fill=tk.X, side=tk.TOP)
        
        lbl_info = tk.Label(top_frame, text="連線設定: IP=127.0.0.1 | Port=502", font=("Arial", 10, "bold"), bg="#e0e0e0")
        lbl_info.pack(side=tk.LEFT, padx=10, pady=5)
        
        self.lbl_status = tk.Label(top_frame, text="狀態: 未連線", fg="red", bg="#e0e0e0", font=("Arial", 10, "bold"))
        self.lbl_status.pack(side=tk.RIGHT, padx=15, pady=5)

        # 主內容框架
        main_frame = tk.Frame(self.root, bg="#f0f0f0")
        main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)

        # --- 左半邊: Holding Registers (FC=03) ---
        hr_frame = tk.LabelFrame(main_frame, text=" FC=03 Holding Register 40001-40064 ", font=("Arial", 11, "bold"), fg="blue", bg="#f0f0f0")
        hr_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=5)

        self.hr_inputs = []  # 儲存 Entry 元件,方便後續更新數值
        
        # 設定:4 列,每列 16 個 (16 * 4 = 64)
        for col in range(4):
            col_subframe = tk.Frame(hr_frame, bg="#f0f0f0")
            col_subframe.pack(side=tk.LEFT, fill=tk.Y, padx=8, pady=5)
            
            for row in range(16):
                index = col * 16 + row
                if index >= 64:
                    break
                
                row_frame = tk.Frame(col_subframe, bg="#f0f0f0")
                row_frame.pack(fill=tk.X, pady=2)
                
                # 顯示索引編號
                lbl_idx = tk.Label(row_frame, text=f"{index:2d}", width=3, anchor="e", bg="#f0f0f0", font=("Courier", 10))
                lbl_idx.pack(side=tk.LEFT, padx=2)
                
                # 數值顯示方塊
                val_entry = ttk.Entry(row_frame, width=7, justify="center")
                val_entry.insert(0, "0")
                val_entry.configure(state="readonly")
                val_entry.pack(side=tk.LEFT, padx=2)
                
                self.hr_inputs.append(val_entry)

        # --- 右半邊: Coil Output (FC=01) ---
        coil_frame = tk.LabelFrame(main_frame, text=" FC=01 Coil output 00001-00064 ", font=("Arial", 11, "bold"), fg="red", bg="#f0f0f0")
        coil_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True, padx=5)

        self.coil_bulbs = []  # 儲存燈號 Label 元件
        
        # 設定:4 列,每列 16 個 (16 * 4 = 64)
        for col in range(4):
            col_subframe = tk.Frame(coil_frame, bg="#f0f0f0")
            col_subframe.pack(side=tk.LEFT, fill=tk.Y, padx=12, pady=5)
            
            for row in range(16):
                index = col * 16 + row
                if index >= 64:
                    break
                
                row_frame = tk.Frame(col_subframe, bg="#f0f0f0")
                row_frame.pack(fill=tk.X, pady=3)
                
                # 顯示索引編號
                lbl_idx = tk.Label(row_frame, text=f"{index:2d}", width=3, anchor="e", bg="#f0f0f0", font=("Courier", 10))
                lbl_idx.pack(side=tk.LEFT, padx=2)
                
                # 用文字與顏色模擬燈泡 (💡 表示 ON,⚫ 表示 OFF)
                lbl_bulb = tk.Label(row_frame, text="⚫ OFF", width=6, fg="gray", bg="#f0f0f0", font=("Arial", 10, "bold"), anchor="w")
                lbl_bulb.pack(side=tk.LEFT, padx=3)
                
                self.coil_bulbs.append(lbl_bulb)

        # 開始定時更新迴圈
        self.update_modbus_data()

    def update_modbus_data(self):
        # 1. 讀取 Holding Registers (從位址 0 開始讀 64 個)
        regs_data = self.client.read_holding_registers(0, 64)
        
        # 2. 讀取 Coils (從位址 0 開始讀 64 個)
        coils_data = self.client.read_coils(0, 64)

        if regs_data and coils_data:
            self.lbl_status.configure(text="狀態: 連線正常", fg="green")
            
            # 更新 Holding Registers 畫面
            for idx, val in enumerate(regs_data):
                if idx < len(self.hr_inputs):
                    # 處理 16-bit 有號整數負數顯示
                    if val > 32767: 
                        val -= 65536
                    
                    self.hr_inputs[idx].configure(state="normal")
                    self.hr_inputs[idx].delete(0, tk.END)
                    self.hr_inputs[idx].insert(0, str(val))
                    self.hr_inputs[idx].configure(state="readonly")
            
            # 更新 Coils 燈號畫面
            for idx, status in enumerate(coils_data):
                if idx < len(self.coil_bulbs):
                    if status:
                        self.coil_bulbs[idx].configure(text="💡 ON", fg="#FF9900") # 橘黃色亮燈
                    else:
                        self.coil_bulbs[idx].configure(text="⚫ OFF", fg="gray")   # 灰色滅燈
        else:
            self.lbl_status.configure(text="狀態: 連線失敗 / 超時", fg="red")

        # 每 1000 毫秒 (1秒) 重新呼叫自己一次
        self.root.after(1000, self.update_modbus_data)

if __name__ == "__main__":
    root = tk.Tk()
    app = ModbusViewerApp(root)
    root.mainloop()

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

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