2026年6月9日 星期二

ModBus + Fuxa + MQTT

 ModBus + Fuxa + MQTT 







這是一個非常實用且經典的工業物聯網(IIoT)與數據可視化架構。在這個架構中,Modbus Poll 負責模擬底層工業設備(如 PLC、感測器),MQTT Broker 擔任資料交換的中台,而 FUXA 則是一套開源的 Web-based SCADA 系統,負責將資料抓回並進行動態網頁監控。

為了讓這個通訊範例能夠順利運作,我們將架構設計如下:

Modbus Poll (設備端) ──[Modbus TCP]──> FUXA (SCADA中心) ──[MQTT]──> MQTT Broker (雲端/中台)

以下為您規劃完整的環境建置與通訊設計步驟:

1. 環境準備與軟體設定

請確保您的電腦上已安裝或準備好以下軟體:

  1. Modbus Poll:用於模擬 Modbus TCP Slave(伺服器)或 Master。在本範例中,我們將其設定為 Modbus TCP Slave(模擬一台 PLC)。

  2. MQTT Broker:可以使用公用的 Broker(例如 broker.hivemq.com),或在本地端用 Docker 運行 Mosquitto

  3. FUXA:確認已啟動 FUXA 服務(通常透過 Node.js 或 Docker 執行,瀏覽器輸入 http://localhost:1881 進入畫面)。

2. 第一階段:Modbus Poll 設定 (模擬工業設備)

我們首先讓 Modbus Poll 模擬一個內含溫度與濕度資料的智能感測器。

步驟 A:建立暫存器

  1. 打開 Modbus Poll,點選選單的 Setup -> Read/Write Definition

  2. 設定如下:

    • Slave ID: 1

    • Function: 03 Read Holding Registers (4x)

    • Address: 0 (從 0 開始)

    • Quantity: 10 (讀取 10 個暫存器)

  3. 點擊 OK

步驟 B:啟動 Modbus TCP 伺服器

  1. 點選選單的 Connection -> Connect...

  2. 在 Connection 下拉選單選擇 Modbus TCP/IP Server

  3. TCP Server Port: 預設 502(如果權限衝突,可改為 5020)。

  4. 點擊 OK 啟動監聽。


ModbusTCPTool

Modbus TCP test tool, support server and client mode, support 4 types of registers, client support regular reading data, data support hexadecimal, binary and other





步驟 C:模擬數據輸入

在編號 40001 (Address 0) 與 40002 (Address 1) 的格子點兩下,分別手動輸入數值:

  • 40001 (Address 0): 輸入 25 (模擬溫度 $25^\circ\text{C}$)

  • 40002 (Address 1): 輸入 60 (模擬濕度 $60\%$)

3. 第二階段:FUXA 設定 (SCADA 核心)

FUXA 在這裡扮演雙重角色:它既要透過 Modbus TCP 向 Modbus Poll 讀取資料,又要透過 MQTT 將資料 發布(Publish) 出去。

步驟 A:新增 Modbus TCP 驅動連線

  1. 打開 FUXA 網頁控制台,切換到 Configuration (設定) 頁面。

  2. Devices 區塊點擊 + Add device

  3. 設定參數:

    • Name: Modbus_PLC

    • Type: Modbus TCP

    • Host: 127.0.0.1 (若在同一台電腦)

    • Port: 502 (需與 Modbus Poll 設定一致)

    • Slave ID: 1

  4. 點擊儲存,確認連線狀態顯示為綠色(Connected)。

步驟 B:在 FUXA 中建立標籤 (Tags)

在剛剛建立的 Modbus_PLC 設備下新增兩個 Tags:

  1. 溫度標籤 (Temperature)

    • Name: Temperature

    • Address: 40001 (或根據 FUXA 格式輸入 F40001 或數值類型,通常選 Int16)

  2. 濕度標籤 (Humidity)

    • Name: Humidity

    • Address: 40002

此時在 FUXA 的 Tag 列表中,應該就能即時看到從 Modbus Poll 傳過來的 2560

步驟 C:新增 MQTT 連線 (轉發數據)

  1. 在 FUXA 的 DevicesConnectivity 區塊(依版本不同),點擊 + Add device/client 並選擇 MQTT

  2. 設定參數:

    • Name: MQTT_Broker

    • URL: mqtt://broker.hivemq.com:1883 (此處以公共測試 Broker 為例)

    • Client ID: 可自訂(如 FUXA_Gateway_01

  3. 儲存並確認連線成功。

步驟 D:設定 Tag 的 MQTT 發布功能

  1. 編輯剛剛建立的 Temperature 標籤。

  2. 找到 PublishMQTT Export 相關設定。

  3. 啟用發布,並指定 Topic:

    • Topic: factory/sensor01/temperature

  4. 同理,將 Humidity 標籤設定發布至:

    • Topic: factory/sensor01/humidity

4. 第三階段:MQTT 軟體驗證 (接收端)

為了驗證數據是否成功經由 FUXA 轉發至 MQTT 網路,我們可以使用任意 MQTT 客戶端軟體(例如 MQTTXMQTT ExplorerAdvanced REST Client)來進行訂閱(Subscribe)。

  1. 打開您的 MQTT 軟體,連線至與 FUXA 相同的 Broker:

    • Host: broker.hivemq.com

    • Port: 1883

  2. 建立連線後,新增一個訂閱主題(Subscription):

    • Topic: factory/sensor01/# (使用萬用字元 # 可以同時接收該路徑下的所有資料)

  3. 驗證結果

    您將會在 MQTT 軟體的接收視窗中,看見定時推送過來的 JSON 格式或純文字訊息,例如:

    JSON
    { "value": 25, "timestamp": 1780000000000 }




















5. 通訊測試與連動驗證

當整體架構打通後,您可以進行以下測試來驗證通訊的即時性:

  1. 回到 Modbus Poll 軟體,將 40001 的數值從 25 修改為 28

  2. 查看 FUXA 的畫面,確認其畫面上顯示的 Temperature 標籤數值已同步變更為 28

  3. 查看 MQTT 軟體,確認收到一筆新的 Payload,其數值已更新為 28

透過這個範例,您成功實作了工業現場總線(Modbus)物聯網雲端協定(MQTT)的數據整合,這也是當前工業 4.0 智慧工廠最核心的通訊架構之一。

Node-Red +SQLite <範例2>

 Node-Red +SQLite <範例2>

直接共用原本的 student.db 設定,不需建立新資料庫檔案。






[{"id":"in_gr_subject_v6","type":"ui_dropdown","z":"d84044bddad55edc","name":"選擇輸入科目","label":"● 選擇欲輸入科目","tooltip":"","place":"請選擇科目","group":"ui_grp_grade_left_form_v6","order":1,"width":0,"height":0,"passthru":true,"multiple":false,"options":[{"label":"國文","value":"chinese","type":"str"},{"label":"英文","value":"english","type":"str"},{"label":"數學","value":"math","type":"str"}],"payload":"","topic":"selected_subject","topicType":"str","className":"","x":140,"y":80,"wires":[["change_save_grade_v6"]]},{"id":"in_gr_id_v6","type":"ui_text_input","z":"d84044bddad55edc","name":"成績學號輸入","label":"1. 學號 (輸入欲登記成績之學號)","tooltip":"","group":"ui_grp_grade_left_form_v6","order":3,"width":0,"height":0,"passthru":false,"mode":"text","delay":0,"topic":"student_id","sendOnBlur":true,"className":"","topicType":"str","x":140,"y":120,"wires":[["change_save_grade_v6"]]},{"id":"in_gr_score_v6","type":"ui_text_input","z":"d84044bddad55edc","name":"分數輸入","label":"2. 分數 (請輸入該科分數 0-100)","tooltip":"","group":"ui_grp_grade_left_form_v6","order":5,"width":0,"height":0,"passthru":false,"mode":"text","delay":0,"topic":"score","sendOnBlur":true,"className":"","topicType":"str","x":120,"y":160,"wires":[["change_save_grade_v6"]]},{"id":"change_save_grade_v6","type":"change","z":"d84044bddad55edc","name":"即時暫存成績欄位資料","rules":[{"t":"set","p":"grade_form[msg.topic]","pt":"flow","to":"payload","tot":"msg"}],"action":"","property":"","from":"","to":"","reg":false,"x":410,"y":120,"wires":[["cd1ed0c6ac95cc06"]]},{"id":"btn_gr_1_v6","type":"ui_button","z":"d84044bddad55edc","name":"鍵1_建立成績表","group":"ui_grp_grade_right_btn_v6","order":1,"width":0,"height":0,"passthru":false,"label":"建立成績資料表","tooltip":"","color":"#ffffff","bgcolor":"#17a2b8","className":"","icon":"","payload":"","payloadType":"str","topic":"CREATE_GRADE_TABLE","topicType":"str","x":140,"y":220,"wires":[["func_grade_core_v6"]]},{"id":"btn_gr_2_v6","type":"ui_button","z":"d84044bddad55edc","name":"鍵2_同步學生名單","group":"ui_grp_grade_right_btn_v6","order":2,"width":0,"height":0,"passthru":false,"label":"從學生系統同步(匯入)名單","tooltip":"","color":"#ffffff","bgcolor":"#ffc107","className":"","icon":"","payload":"","payloadType":"str","topic":"SYNC_STUDENTS","topicType":"str","x":160,"y":260,"wires":[["func_grade_core_v6"]]},{"id":"btn_gr_3_v6","type":"ui_button","z":"d84044bddad55edc","name":"鍵3_輸入更正科目成績","group":"ui_grp_grade_right_btn_v6","order":3,"width":0,"height":0,"passthru":false,"label":"登記 / 更正該科成績","tooltip":"","color":"#ffffff","bgcolor":"#28a745","className":"","icon":"","payload":"","payloadType":"str","topic":"UPDATE_GRADE","topicType":"str","x":170,"y":300,"wires":[["func_grade_core_v6"]]},{"id":"btn_gr_4_v6","type":"ui_button","z":"d84044bddad55edc","name":"鍵4_結算顯示所有成績","group":"ui_grp_grade_right_btn_v6","order":4,"width":0,"height":0,"passthru":false,"label":"結算並顯示所有成績","tooltip":"","color":"#ffffff","bgcolor":"#007bff","className":"","icon":"","payload":"","payloadType":"str","topic":"DISPLAY_ALL_GRADES","topicType":"str","x":170,"y":340,"wires":[["func_grade_core_v6"]]},{"id":"btn_gr_5_v6","type":"ui_button","z":"d84044bddad55edc","name":"鍵5_查詢單生健康成績","group":"ui_grp_grade_right_btn_v6","order":5,"width":0,"height":0,"passthru":false,"label":"查詢單生資料(依學號)","tooltip":"","color":"#ffffff","bgcolor":"#6c757d","className":"","icon":"","payload":"","payloadType":"str","topic":"QUERY_GRADE","topicType":"str","x":170,"y":380,"wires":[["func_grade_core_v6"]]},{"id":"func_grade_core_v6","type":"function","z":"d84044bddad55edc","name":"成績系統核心字串拼接邏輯","func":"var action = msg.topic;\nvar form = flow.get(\"grade_form\") || {};\n\nvar subject = form.selected_subject ? form.selected_subject.trim() : \"\";\nvar student_id = form.student_id ? form.student_id.trim() : \"\";\nvar score = (form.score !== undefined && form.score !== \"\") ? form.score.trim() : \"0\";\n\nswitch(action) {\n    case \"CREATE_GRADE_TABLE\":\n        msg.topic = `CREATE TABLE IF NOT EXISTS grades (\n            student_id TEXT PRIMARY KEY,\n            chinese REAL DEFAULT 0,\n            english REAL DEFAULT 0,\n            math REAL DEFAULT 0,\n            total REAL GENERATED ALWAYS AS (chinese + english + math),\n            average REAL GENERATED ALWAYS AS ((chinese + english + math) / 3.0),\n            FOREIGN KEY(student_id) REFERENCES students(student_id) ON DELETE CASCADE\n        );`;\n        break;\n        \n    case \"SYNC_STUDENTS\":\n        msg.topic = \"INSERT OR IGNORE INTO grades (student_id) SELECT student_id FROM students;\";\n        break;\n        \n    case \"UPDATE_GRADE\":\n        if (student_id === \"\") {\n            node.error(\"【系統警告】登記失敗:請輸入學號!\");\n            return null;\n        }\n        if (subject === \"\") {\n            node.error(\"【系統警告】登記失敗:請先在下拉選單選擇輸入科目!\");\n            return null;\n        }\n        // 純字串拼接,因為分數是數字型態,不需要包單引號\n        msg.topic = \"UPDATE grades SET \" + subject + \" = \" + score + \" WHERE student_id = '\" + student_id + \"';\";\n        break;\n        \n    case \"QUERY_GRADE\":\n        if (student_id === \"\") {\n            node.error(\"【系統警告】查詢失敗:請輸入學號!\");\n            return null;\n        }\n        // 使用 LEFT JOIN 實時跨表從 students 關聯抓取姓名 (s.name AS name)\n        msg.topic = `SELECT g.student_id, s.name AS name, g.chinese, g.english, g.math, g.total, ROUND(g.average, 2) AS average \n                     FROM grades g \n                     LEFT JOIN students s ON g.student_id = s.student_id \n                     WHERE g.student_id = '${student_id}';`;\n        break;\n        \n    case \"DISPLAY_ALL_GRADES\":\n        // 顯示所有資料共 7 欄位,姓名完全實時關聯取得\n        msg.topic = `SELECT g.student_id, s.name AS name, g.chinese, g.english, g.math, g.total, ROUND(g.average, 2) AS average \n                     FROM grades g \n                     LEFT JOIN students s ON g.student_id = s.student_id \n                     ORDER BY g.student_id ASC;`;\n        break;\n        \n    default:\n        return null;\n}\n\nreturn msg;","outputs":1,"timeout":"","noerr":0,"initialize":"","finalize":"","libs":[],"x":440,"y":300,"wires":[["sqlite_node_grade_v6"]]},{"id":"sqlite_node_grade_v6","type":"sqlite","z":"d84044bddad55edc","mydb":"sqlite_config_switch_v2","sqlquery":"msg.topic","sql":"","name":"SQLite 成績庫","x":680,"y":300,"wires":[["ui_table_grade_v6"]]},{"id":"ui_table_grade_v6","type":"ui_table","z":"d84044bddad55edc","group":"ui_grp_grade_bottom_table_v6","name":"學生成績大面板","order":1,"width":12,"height":6,"columns":[{"field":"student_id","title":"學號","width":"14%","align":"center","formatter":"plaintext","formatterParams":{"target":"_blank"}},{"field":"name","title":"姓名","width":"14%","align":"center","formatter":"plaintext","formatterParams":{"target":"_blank"}},{"field":"chinese","title":"國文","width":"12%","align":"center","formatter":"plaintext","formatterParams":{"target":"_blank"}},{"field":"english","title":"英文","width":"12%","align":"center","formatter":"plaintext","formatterParams":{"target":"_blank"}},{"field":"math","title":"數學","width":"12%","align":"center","formatter":"plaintext","formatterParams":{"target":"_blank"}},{"field":"total","title":"總分","width":"18%","align":"center","formatter":"plaintext","formatterParams":{"target":"_blank"}},{"field":"average","title":"平均分數","width":"18%","align":"center","formatter":"plaintext","formatterParams":{"target":"_blank"}}],"outputs":0,"cts":false,"x":860,"y":300,"wires":[]},{"id":"cd1ed0c6ac95cc06","type":"debug","z":"d84044bddad55edc","name":"debug 391","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"false","statusVal":"","statusType":"auto","x":610,"y":120,"wires":[]},{"id":"ui_grp_grade_left_form_v6","type":"ui_group","name":"1. 成績輸入與科目選擇","tab":"d0cd6479caf695f3","order":4,"disp":true,"width":"8","collapse":false,"className":""},{"id":"ui_grp_grade_right_btn_v6","type":"ui_group","name":"2. 成績操作快捷鍵","tab":"d0cd6479caf695f3","order":5,"disp":true,"width":"4","collapse":false,"className":""},{"id":"sqlite_config_switch_v2","type":"sqlitedb","db":"student.db","mode":"RWC"},{"id":"ui_grp_grade_bottom_table_v6","type":"ui_group","name":"3. 成績資料顯示面板 (自動關聯姓名、結算總分與平均)","tab":"d0cd6479caf695f3","order":6,"disp":true,"width":"12","collapse":false,"className":""},{"id":"d0cd6479caf695f3","type":"ui_tab","name":"學生成績管理系統","icon":"dashboard","disabled":false,"hidden":false}]


這份 JSON 程式碼構建了系統的 「第二部分:學生成績管理系統」。它與第一部分(學生資料系統)共享同一個 SQLite 資料庫檔案 student.db,並透過經典的 Node-RED Dashboard 元件,完美實現了前端數據收集、動態 SQL 拼接與後端關聯式資料庫的互動。

以下為您詳細拆解這段程式的架構與運作邏輯:

🏗️ 一、 前端網頁佈局 (UI Dashboard)

這段 Flow 同樣利用 Grid(網格)系統將網頁畫面精準地切分為三個區塊,維持系統操作的一致性:

  • 左側輸入區 (ui_grp_grade_left_form_v6,寬度 8): 包含 3 個核心元件:一個下拉選單(選擇國文、英文或數學)與兩個文字輸入框(學號、分數)。

  • 右側按鍵區 (ui_grp_grade_right_btn_v6,寬度 4): 垂直排列 5 個功能實體按鈕(建立成績表、同步名單、登記/更正、結算顯示、單生查詢)。

  • 下方大面板 (ui_grp_grade_bottom_table_v6,寬度 12): 放置一個 7 欄位的表格元件 (ui_table),用來完整呈現包含姓名與計算結果的成绩單。

🔄 二、 數據流程與暫存機制

與第一部分相同,為了避免使用者每打一個字就去衝擊資料庫,系統採用了「非同步暫存」設計:

  1. 當使用者在網頁左側操作「選擇科目」、「輸入學號」或「輸入分數」時,這三個節點會將各自的數值發送到 「即時暫存成績欄位資料」(Change 節點)。

  2. Change 節點會以各自的 msg.topicselected_subjectstudent_idscore)作為鑰匙,將數值存入 Node-RED 的記憶體環境變數 flow.grade_form 中。

  3. 同時,它會將資料複製一份送往右上方的 「debug 391」 節點,方便您在除錯視窗即時監看目前輸入的暫存內容是否正確。

🧠 三、 核心 Function 邏輯解析 (func_grade_core_v6)

當使用者點擊右側 5 個按鈕中的任意一個時,按鈕會發送專屬的 msg.topic 指令(如 ADDUPDATE_GRADE 等)進入大腦 Function 節點。

Function 節點會從記憶體中取出暫存的科目、學號與分數,並根據點擊的按鈕執行 switch(action) 進行 SQL 字串拼接。以下是各個按鍵觸發的 SQL 邏輯:

鍵 1:建立成績資料表 (CREATE_GRADE_TABLE)

SQL
CREATE TABLE IF NOT EXISTS grades (
    student_id TEXT PRIMARY KEY,
    chinese REAL DEFAULT 0,
    english REAL DEFAULT 0,
    math REAL DEFAULT 0,
    total REAL GENERATED ALWAYS AS (chinese + english + math),
    average REAL GENERATED ALWAYS AS ((chinese + english + math) / 3.0),
    FOREIGN KEY(student_id) REFERENCES students(student_id) ON DELETE CASCADE
);
  • 說明: 這裏完美應用了您指定的 SQLite 進階技術。totalaverage 欄位被設定為 GENERATED ALWAYS AS(產生欄位)。這代表總分與平均完全不需要寫程式去計算,SQLite 驅動層會在分數寫入的瞬間自動在底層算好。

鍵 2:從學生系統同步(匯入)名單 (SYNC_STUDENTS)

SQL
INSERT OR IGNORE INTO grades (student_id) SELECT student_id FROM students;
  • 說明: 這行指令解決了「名單必須由第一系統取得」的需求。它會直接去學生基本資料表(students)把所有人的學號撈出來並倒進成績表(grades)中。使用 INSERT OR IGNORE 可以確保重複點擊時不會因為主鍵衝突而報錯。

鍵 3:登記 / 更正該科成績 (UPDATE_GRADE)

JavaScript
msg.topic = "UPDATE grades SET " + subject + " = " + score + " WHERE student_id = '" + student_id + "';";
  • 說明: 這裏實現了「一科目一科目連續輸入」的設計。程式會動態讀取變數 subject 的值(chineseenglishmath),並動態拼接成 SQL 欄位。

  • 範例: 如果您選了國文,輸入學號 DB112203,分數 95,拼接出來就是: UPDATE grades SET chinese = 95 WHERE student_id = 'DB112203'; 因為 score 提取出來後是數字,所以拼接時它很聰明地沒有包單引號;而學號是文字,兩側確實包了單引號 '${student_id}'

鍵 4 & 鍵 5:結算顯示與單生查詢 (DISPLAY_ALL_GRADES / QUERY_GRADE)

SQL
SELECT g.student_id, s.name AS name, g.chinese, g.english, g.math, g.total, ROUND(g.average, 2) AS average 
FROM grades g 
LEFT JOIN students s ON g.student_id = s.student_id
  • 說明(核心技術): 因為成績表本身只存儲學號,不存姓名。為了動態得到姓名,這裏使用了 LEFT JOIN students s ON g.student_id = s.student_id(左外部連接)

  • 它會實時(Real-time)根據學號去第一部分的基本資料表撈出對應的 name(姓名),並利用 ROUND(g.average, 2) 將平均分數四捨五入到小數點後兩位。

🗄️ 四、 後端執行與前端表格顯示

  1. SQLite 成績庫節點 (sqlite_node_grade_v6): 其 SQL Type 設定為 via msg.topic(即 sqlquery: "msg.topic"),它會死死接收前面 Function 拼接好的純文字 SQL 命令並直接送入 student.db 執行。

  2. 學生成績大面板 (ui_table_grade_v6): 當執行查詢或顯示所有時,SQLite 回傳的 7 欄位數據(學號、姓名、國文、英文、數學、總分、平均分數)會精準流入表格元件。因為表格內部設定的欄位 Key(field: "name", field: "total" 等)與 SQL 查詢出來的別名完全對齊,畫面便能毫無時差地渲染出一張精美的成績大報表。

ModBus + Fuxa + MQTT

  ModBus + Fuxa + MQTT  這是一個非常實用且經典的工業物聯網(IIoT)與數據可視化架構。在這個架構中, Modbus Poll 負責模擬底層工業設備(如 PLC、感測器), MQTT Broker 擔任資料交換的中台,而 FUXA 則是一套開源的 W...