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()

沒有留言:

張貼留言

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

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