2026年3月2日 星期一

MQTT 基本觀念與實驗(1)

MQTT 基本觀念與實驗 (1)


一個基於 ESP32 的物聯網系統架構,描述了硬體端、雲端代理伺服器(Broker)以及多個客戶端(Clients)之間的互動關係

以下是根據圖片內容對該系統關係的詳細說明:

1. 硬體終端 (CLIENT 3 / ESP32)

這是系統的實體感測與執行層

  • 感測器數據採集:透過 DHT22 感測器讀取環境的溫度與濕度數據

  • 在地顯示:將溫濕度數值即時顯示於 I2C 16x2 LCD 螢幕上(例如:Temp: 23.7 C, Humi: 76.5%)

  • 訊息發布 (Publish):將讀取的數據上傳至 MQTT Broker

    • 溫度主題alex9ufo/mqtt/ex1/Temp

    • 濕度主題alex9ufo/mqtt/ex1/Humi

  • 執行器控制:訂閱主題 alex9ufo/mqtt/ex1/led 以接收控制指令,操作 LED 的狀態(如亮、滅、閃爍或計時)

2. MQTT 雲端代理伺服器 (SERVER / BROKER)

該系統使用 MQTTGO (NMking Technology) 作為核心通訊中轉站

  • 訊息路由:負責接收來自 ESP32 的溫濕度訊息,並將其轉發給所有訂閱該主題的客戶端(如 Python 介面)

  • 指令轉發:接收來自控制端(Client 1 或 2)的 LED 指令,並發送給 ESP32

3. 監控與控制端 (CLIENT 1 & 2)

這些客戶端負責與使用者互動:

  • Python GUI 介面 (Client 2)

    • 數據監測:訂閱溫濕度主題,並以圓餅圖或文字形式顯示「當前溫度: 23.7°C」與「當前濕度: 76.5%」

    • 連線狀態:顯示 MQTT 的連線指示燈

    • 遠端控制:提供按鈕發送指令至 led 主題,包含 ON(亮)、OFF(滅)、Flash(閃爍)及 Timer: 5s(計時 5 秒)

  • MQTTBOX (Client 1)

    • 作為通訊測試工具,用於監看或手動發布主題訊息,確保系統通訊正常

總結關係流程

  1. 數據流:[ESP32] → 發布溫濕度 → [MQTT Broker] → 轉送訊息 → [Python 介面]

  2. 指令流:[Python 介面] → 發布 LED 指令 → [MQTT Broker] → 轉送指令 → [ESP32] → 控制 LED 動作


在 MQTT(Message Queuing Telemetry Transport)架構中,核心概念是**「發行(Publish)」「訂閱(Subscribe)」**,這是一種「非同步」的通訊方式,所有訊息都透過一個中間人(Broker)來傳遞。

以下根據你的接線圖 與架構圖 ,詳細說明這套系統的運作邏輯:

1. 角色定義

  • 發行者 (Publisher):產生訊息並發送到特定「主題 (Topic)」的客戶端。在你的案例中,ESP32 與 Python 程式都同時扮演這個角色。

  • 訂閱者 (Subscriber):向 Broker 登記想要接收某個「主題」訊息的客戶端。

  • 代理伺服器 (Broker):即 mqttgo.io 。它負責接收所有發行者的訊息,並將訊息精準地轉發給所有有訂閱該主題的客戶端。


2. 數據流:從感測器到監控介面 (上行)

這是將環境數據傳送到雲端與電腦的過程:

  • 動作:發行 (Publish)

    • 發行者:ESP32

    • 主題與內容

      1. 主題:alex9ufo/mqtt/ex1/Temp,內容:例如 23.7

      2. 主題:alex9ufo/mqtt/ex1/Humi,內容:例如 76.5

    • 頻率:每 5 秒發送一次。 

    • DHT22 的感測極限(溫度  -40  C ~ +80 C ,濕度 0% ~ 100%

  • 動作:訂閱 (Subscribe)

    • 訂閱者:Python Tkinter 程式 (Client 2) 與 MQTTBOX (Client 1)

    • 結果:當 Broker 收到 ESP32 的溫濕度時,會立刻推播給 Python 程式,進而更新介面上的圓餅圖


3. 指令流:從電腦控制硬體 (下行)

這是遠端控制 LED 燈光的過程:

  • 動作:發行 (Publish)

    • 發行者:Python Tkinter 程式

    • 主題alex9ufo/mqtt/ex1/led

    • 指令內容:根據按下不同的按鈕,發送 onoffflashtimer

  • 動作:訂閱 (Subscribe)

    • 訂閱者:ESP32 (Client 3)

    • 行為邏輯

      • ESP32 一直在監聽 .../led 這個主題

      • 一旦收到 on,ESP32 的程式碼會將 GPIO 2 輸出高電位,點亮 LED

      • 一旦收到 timer,ESP32 啟動內部計時器,5 秒後自動熄滅 LED


4. 運作特性總結

  1. 解耦性:ESP32 並不需要知道是誰在控制它(可能是 Python 程式,也可能是 MQTTBOX),它只需要訂閱正確的主題並對指令做出反應

  2. 多對多:如果同時有五台電腦執行 Python 程式並訂閱了溫濕度主題,這五台電腦都會同時看到最新的數據

  3. 即時性:MQTT 是一種極輕量化的協定,非常適合像 ESP32 這種記憶體有限的嵌入式設備,能達成毫秒等級的指令反應

// --- 硬體接線設定 ---
#define DHTPIN 13
#define DHTTYPE DHT22
#define LED_PIN 2
#define SDA_PIN 21
#define SCL_PIN 22

// --- WiFi 與 MQTT 設定 ---
const char* ssid = "Wokwi-GUEST";
const char* password = "";
const char* mqtt_server = "mqttgo.io";
const char* topicTemp = "alex9ufo/mqtt/ex1/Temp";
const char* topicHumi = "alex9ufo/mqtt/ex1/Humi";
const char* topicLED = "alex9ufo/mqtt/ex1/led";

在物聯網開發中,選擇一個穩定且免費的 MQTT Broker 進行測試是非常重要的。

根據你的架構圖,你目前使用的是 MQTTGO (NMking Technology) ,這是一個非常適合在地化測試的選擇。

除了 MQTTGO 之外,以下是全球開發者社群中最常用且穩定的免費公共 MQTT Broker:


1. HiveMQ Public Broker

這是目前最受歡迎的公共測試伺服器之一,擁有非常友善的線上查看工具。

  • 伺服器位址: broker.hivemq.com

  • 連接埠: 1883 (TCP), 8000 (WebSockets)

  • 優點: 提供線上網頁客戶端,不需要安裝軟體就能檢查訊息是否發行成功。

2. Mosquitto (test.mosquitto.org)

由 Eclipse 基金會維護,是 MQTT 協定的官方參考實作。

  • 伺服器位址: test.mosquitto.org

  • 連接埠: 1883 (不加密), 8883 (SSL 加密)

  • 優點: 支援多種安全連線模式(TLS/SSL),適合需要測試加密連線的專案。

3. EMQX Public Broker

EMQX 是目前效能極高的分散式 MQTT Broker。

  • 伺服器位址: broker.emqx.io

  • 連接埠: 1883, 8083 (WebSockets)

  • 優點: 延遲極低,全球擁有多個節點,穩定性高。

4. Shiftr.io

這是一個非常特殊的視覺化 Broker。

  • 特性: 它可以將你所有連接的設備與主題(Topic)以互動式地圖的方式呈現,非常直觀。

  • 免費額度: 提供一定數量的免費連接數,適合教學與展示。


公共 Broker 使用注意事項 (重要)

在使用這些免費伺服器時,請務必遵守以下規範:

  1. 隱私風險: 公共 Broker 是完全公開的。任何人只要訂閱 # (通配符),就能看到你發送的所有數據 。請勿傳送機密資訊(如真實密碼、個人住址)。

  2. 主題唯一性: 為了避免跟別人的專案衝突,建議你的主題名稱要包含獨特的 ID,例如像你現在使用的 alex9ufo/...

  3. 不保證持久性: 免費伺服器隨時可能重啟或清除數據,不建議用於商業或需要 24/7 運作的正式環境。

總結建議

如果你在台灣開發,MQTTGO 的連線速度通常很理想。如果你需要更多的診斷工具或 Web 連線測試,HiveMQEMQX 是首選。


















WOKWI程式

#include <WiFi.h>
#include <PubSubClient.h>
#include <DHT.h>
#include <Wire.h>
#include <LiquidCrystal_I2C.h>

// --- 硬體接線設定 ---
#define DHTPIN 13
#define DHTTYPE DHT22
#define LED_PIN 2
#define SDA_PIN 21
#define SCL_PIN 22

// --- WiFi 與 MQTT 設定 ---
const char* ssid = "Wokwi-GUEST";
const char* password = "";
const char* mqtt_server = "mqttgo.io";
const char* topicTemp = "alex9ufo/mqtt/ex1/Temp";
const char* topicHumi = "alex9ufo/mqtt/ex1/Humi";
const char* topicLED = "alex9ufo/mqtt/ex1/led";


// --- 全域變數 ---
String ledMode = "off";
unsigned long lastMsg = 0;
unsigned long myTimerStart = 0; // 修正後的變數名稱,避開系統關鍵字
bool timerActive = false;
unsigned long lastFlash = 0;
bool flashState = false;

DHT dht(DHTPIN, DHTTYPE);
LiquidCrystal_I2C lcd(0x27, 16, 2); // 若沒畫面,請嘗試 0x3F 0x27
WiFiClient espClient;
PubSubClient client(espClient);

// --- 函數定義 ---

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("\nWiFi connected");
  Serial.println("IP address: ");
  Serial.println(WiFi.localIP());
}

// 接收 MQTT 訊息的回傳函數
void callback(char* topic, byte* payload, unsigned int length) {
  String message = "";
  for (int i = 0; i < length; i++) {
    message += (char)payload[i];
  }
 
  Serial.print("Message arrived [");
  Serial.print(topic);
  Serial.print("] ");
  Serial.println(message);

  if (String(topic) == topicLED) {
    ledMode = message;
    timerActive = false; // 重置計時器狀態
   
    if (ledMode == "on") {
      digitalWrite(LED_PIN, HIGH);
    } else if (ledMode == "off") {
      digitalWrite(LED_PIN, LOW);
    } else if (ledMode == "timer") {
      digitalWrite(LED_PIN, HIGH);
      myTimerStart = millis(); // 記錄啟動時間
      timerActive = true;
    } else if (ledMode == "flash") {
      // flash 邏輯會在 loop 中處理
    }
  }
}

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(topicLED); // 訂閱 LED 控制主題
    } else {
      Serial.print("failed, rc=");
      Serial.print(client.state());
      Serial.println(" try again in 5 seconds");
      delay(5000);
    }
  }
}

void setup() {
  Serial.begin(115200);
  pinMode(LED_PIN, OUTPUT);
  digitalWrite(LED_PIN, LOW);
 
  // 初始化 I2C 與 LCD
  Wire.begin(SDA_PIN, SCL_PIN);
  lcd.init();
  lcd.backlight();
  lcd.setCursor(0, 0);
  lcd.print("MQTT Loading...");

  dht.begin();
  setup_wifi();
  client.setServer(mqtt_server, 1883);
  client.setCallback(callback);
}

void loop() {
  if (!client.connected()) {
    reconnect();
  }
  client.loop();

  // --- LED 模式處理邏輯 ---
  if (ledMode == "flash") {
    // 每 0.5 秒 (500ms) 切換一次狀態
    if (millis() - lastFlash >= 500) {
      lastFlash = millis();
      flashState = !flashState;
      digitalWrite(LED_PIN, flashState);
    }
  }
  else if (ledMode == "timer" && timerActive) {
    // 亮 5 秒後自動熄滅
    if (millis() - myTimerStart >= 5000) {
      digitalWrite(LED_PIN, LOW);
      timerActive = false;
      ledMode = "off";
      Serial.println("Timer finished: LED OFF");
    }
  }

  // --- 溫濕度讀取與發布 (每 5 秒更新一次) ---
  unsigned long now = millis();
  if (now - lastMsg > 5000) {
    lastMsg = now;
    float h = dht.readHumidity();
    float t = dht.readTemperature();

    if (isnan(h) || isnan(t)) {
      Serial.println("Failed to read from DHT sensor!");
      lcd.setCursor(0, 0);
      lcd.print("Sensor Error   ");
      return;
    }

    // 發布到 MQTT Broker
    client.publish(topicTemp, String(t).c_str());
    client.publish(topicHumi, String(h).c_str());

    // 顯示於 LCD 1602
    lcd.setCursor(0, 0);
    lcd.print("Temp: ");
    lcd.print(t, 1);
    lcd.print(" C   "); // 多空格用於清除舊殘影
   
    lcd.setCursor(0, 1);
    lcd.print("Humi: ");
    lcd.print(h, 1);
    lcd.print(" %   ");

    Serial.printf("Published - T: %.1f, H: %.1f\n", t, h);
  }
}

程式碼是一個完整的 IoT 智慧溫濕度監控系統。它結合了硬體感測(DHT22)、在地顯示(LCD 1602)以及遠端雲端控制(MQTT)。

以下我為你將這段程式碼拆解為四大核心模組進行詳細說明:


1. 網路連線模組 (WiFi & MQTT)

這部分負責讓 ESP32 能夠「上網」並與 mqttgo.io 伺服器溝通。

  • setup_wifi(): 這是標準的 WiFi 連線流程。它會不斷檢查狀態直到連上為止。在 Wokwi 模擬器中,Wokwi-GUEST 是免密碼的。

  • reconnect(): 這是 MQTT 的生命線。如果網路斷掉或伺服器失聯,它會自動嘗試重新連線,並在成功後重新訂閱 LED 控制的主題 (alex9ufo/mqtt/ex1/led)。

  • clientId: 程式碼中使用了隨機生成的 ID,這是為了避免多人同時使用同一個伺服器時產生踢人斷線的問題。

2. 感測與發布模組 (DHT22 & LCD)

這部分負責「採集數據」並「對外展示」。

  • 每 5 秒更新一次: 透過 millis() - lastMsg > 5000 判斷時間,這比使用 delay(5000) 好,因為它不會讓系統卡死,讓 MQTT 能持續接收指令。

  • 數據發布: 讀取到的溫度與濕度會分別轉換成字串,推送到 .../Temp.../Humi 兩個主題。

  • 在地顯示: lcd.print 會同步更新資訊到 16x2 的螢幕上。

3. 遠端指令處理 (MQTT Callback)

當你從手機或電腦發送訊息到 alex9ufo/mqtt/ex1/led 時,callback() 函數會被觸發。

  • 它會根據接收到的內容 (on, off, timer, flash) 來改變 ledMode 變數。

  • 這就像是 ESP32 的耳目,隨時監聽遠端的命令。

4. LED 行為邏輯 (核心控制)

這是在 loop() 中根據 ledMode 執行的具體動作: | 模式 | 行為邏輯 | | :--- | :--- | | on | 恆亮(在 callback 中直接設定)。 | | off | 恆滅(在 callback 中直接設定)。 | | flash | 透過 millis() 計算,每 500ms 切換一次電位,達成閃爍效果。 | | timer | 收到指令時亮起,並記錄當下時間,滿 5 秒後程式會自動將其設為 off。 |


Python 程式
import tkinter as tk import paho.mqtt.client as mqtt import matplotlib.pyplot as plt from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg import matplotlib # --- 解決中文字型問題 --- # 設定 Matplotlib 支援中文 (微軟正黑體) matplotlib.rcParams['font.family'] = ['Microsoft JhengHei', 'sans-serif'] matplotlib.rcParams['axes.unicode_minus'] = False # 解決負號顯示問題 # --- 設定參數 --- MQTT_SERVER = "mqttgo.io" TOPIC_TEMP = "alex9ufo/mqtt/ex1/Temp" TOPIC_HUMI = "alex9ufo/mqtt/ex1/Humi" TOPIC_LED = "alex9ufo/mqtt/ex1/led" class MQTTApp: def __init__(self, root): self.root = root self.root.title("ESP32 MQTT 控制面板 (DHT22 全量程版)") self.root.geometry("600x650") self.temp_val = 0.0 self.humi_val = 0.0 self.setup_ui() self.setup_mqtt() def setup_ui(self): # --- 連線狀態指示燈 --- status_frame = tk.Frame(self.root) status_frame.pack(pady=10) tk.Label(status_frame, text="連線狀態: ").pack(side=tk.LEFT) self.canvas_status = tk.Canvas(status_frame, width=20, height=20) self.canvas_status.pack(side=tk.LEFT) self.status_light = self.canvas_status.create_oval(2, 2, 18, 18, fill="red") # --- LED 控制按鈕 --- ctrl_frame = tk.LabelFrame(self.root, text="LED 遠端控制", padx=10, pady=10) ctrl_frame.pack(pady=10, fill="x", padx=20) btns = [ ("亮燈 (ON)", "on"), ("滅燈 (OFF)", "off"), ("閃爍 (Flash)", "flash"), ("計時 (Timer 5s)", "timer") ] for text, cmd in btns: btn = tk.Button(ctrl_frame, text=text, width=15, command=lambda c=cmd: self.publish_led(c)) btn.pack(side=tk.LEFT, padx=5, expand=True) # --- 圖表區 --- self.fig, (self.ax1, self.ax2) = plt.subplots(1, 2, figsize=(6, 3)) self.canvas_plt = FigureCanvasTkAgg(self.fig, master=self.root) self.canvas_plt.get_tk_widget().pack(pady=20) self.update_charts() def setup_mqtt(self): self.client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2) self.client.on_connect = self.on_connect self.client.on_message = self.on_message self.client.on_disconnect = self.on_disconnect try: self.client.connect(MQTT_SERVER, 1883, 60) self.client.loop_start() except Exception as e: print(f"無法連接 MQTT: {e}") def on_connect(self, client, userdata, flags, rc, properties=None): if rc == 0: print("MQTT 連線成功") self.canvas_status.itemconfig(self.status_light, fill="green") self.client.subscribe([(TOPIC_TEMP, 0), (TOPIC_HUMI, 0)]) else: self.canvas_status.itemconfig(self.status_light, fill="red") def on_disconnect(self, client, userdata, disconnect_flags, rc, properties=None): print("MQTT 斷開連線") self.canvas_status.itemconfig(self.status_light, fill="red") def on_message(self, client, userdata, msg): try: payload = msg.payload.decode() if msg.topic == TOPIC_TEMP: self.temp_val = float(payload) elif msg.topic == TOPIC_HUMI: self.humi_val = float(payload) self.root.after(0, self.update_charts) except Exception as e: print(f"處理訊息錯誤: {e}") def publish_led(self, command): self.client.publish(TOPIC_LED, command) print(f"已發送指令: {command}") def update_charts(self): self.ax1.clear() self.ax2.clear() # --- 1. 溫度圓餅圖 (-40 到 +80) --- # 總範圍 = 80 - (-40) = 120 # 將數值平移:若溫度是 -40,平移後為 0;若溫度是 80,平移後為 120 temp_offset = self.temp_val + 40 temp_clipped = max(0, min(120, temp_offset)) # 限制在 0~120 避免繪圖錯誤 # 繪製圓餅圖 (數值, 剩餘空間) self.ax1.pie([temp_clipped, 120 - temp_clipped], labels=["溫度", ""], colors=["#ff9999", "#eeeeee"], startangle=90, counterclock=False) self.ax1.set_title(f"溫度範圍: -40~+80\n當前: {self.temp_val}°C") # --- 2. 濕度圓餅圖 (0 到 100) --- humi_clipped = max(0, min(100, self.humi_val)) # 限制在 0~100 之間 self.ax2.pie([humi_clipped, 100 - humi_clipped], labels=["濕度", ""], colors=["#66b3ff", "#eeeeee"], startangle=90, counterclock=False) self.ax2.set_title(f"濕度範圍: 0~100\n當前: {self.humi_val}%") self.canvas_plt.draw() if __name__ == "__main__": root = tk.Tk() app = MQTTApp(root) root.mainloop()




Python 程式碼是一個結合了 GUI(Tkinter)、數據視覺化(Matplotlib)與物聯網通訊(MQTT)的完整客戶端。

以下是逐行詳細說明:


1. 匯入庫與環境設定

Python
import tkinter as tk                             # 建立視窗介面的標準庫
import paho.mqtt.client as mqtt                  # MQTT 通訊協定庫
import matplotlib.pyplot as plt                 # 繪圖工具
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg # 將圖表嵌入 Tkinter 的工具
import matplotlib
  • 這部分載入了所有必要的工具,特別是 FigureCanvasTkAgg,它是連接「圖表」與「視窗視窗」的橋樑。

2. 中文字型補丁

Python
matplotlib.rcParams['font.family'] = ['Microsoft JhengHei', 'sans-serif']
matplotlib.rcParams['axes.unicode_minus'] = False 
  • 第 1 行:指定 Matplotlib 使用「微軟正黑體」,否則圖表中的中文會變「豆腐塊」。

  • 第 2 行:確保座標軸上的負號 (-) 能正常顯示。

3. 常數設定

Python
MQTT_SERVER = "mqttgo.io"
TOPIC_TEMP = "alex9ufo/mqtt/ex1/Temp"
TOPIC_HUMI = "alex9ufo/mqtt/ex1/Humi"
TOPIC_LED = "alex9ufo/mqtt/ex1/led"
  • 定義了 MQTT 伺服器位址與三個通訊主題(訂閱溫濕度、發布 LED 指令)。

4. 類別初始化 __init__

Python
class MQTTApp:
    def __init__(self, root):
        self.root = root
        self.root.title("ESP32 MQTT 控制面板 (2026 版)")
        self.root.geometry("600x650")

        self.temp_val = 0.0  # 儲存目前的溫度值
        self.humi_val = 0.0  # 儲存目前的濕度值

        self.setup_ui()    # 初始化介面佈局
        self.setup_mqtt()  # 初始化通訊連線
  • 這是程式的起點,設定視窗大小並準備存放數據的變數。

5. 建立 UI 介面 setup_ui

Python
    def setup_ui(self):
        # --- 連線狀態指示燈 ---
        status_frame = tk.Frame(self.root)
        status_frame.pack(pady=10)
        tk.Label(status_frame, text="連線狀態: ").pack(side=tk.LEFT)
        self.canvas_status = tk.Canvas(status_frame, width=20, height=20)
        self.canvas_status.pack(side=tk.LEFT)
        self.status_light = self.canvas_status.create_oval(2, 2, 18, 18, fill="red")
  • 建立一個圓形指示燈(預設紅色),代表 MQTT 是否連線成功。

Python
        # --- LED 控制按鈕 ---
        ctrl_frame = tk.LabelFrame(self.root, text="LED 遠端控制", padx=10, pady=10)
        ctrl_frame.pack(pady=10, fill="x", padx=20)

        btns = [ ("亮燈 (ON)", "on"), ("滅燈 (OFF)", "off"), ... ]

        for text, cmd in btns:
            btn = tk.Button(ctrl_frame, text=text, ..., 
                            command=lambda c=cmd: self.publish_led(c))
            btn.pack(side=tk.LEFT, padx=5, expand=True)
  • 利用迴圈產生四個按鈕。這裡用了 lambda 技巧,讓不同按鈕被按下時,會帶入不同的指令字串(如 "on" 或 "flash")到發送函數。

Python
        # --- 圖表區 ---
        self.fig, (self.ax1, self.ax2) = plt.subplots(1, 2, figsize=(6, 3))
        self.canvas_plt = FigureCanvasTkAgg(self.fig, master=self.root)
        self.canvas_plt.get_tk_widget().pack(pady=20)
  • 建立一個包含左右兩個子圖的畫布,並將其置於視窗中心。

6. MQTT 連線邏輯 setup_mqtt

Python
    def setup_mqtt(self):
        self.client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2)
        self.client.on_connect = self.on_connect
        self.client.on_message = self.on_message
        self.client.on_disconnect = self.on_disconnect

        self.client.connect(MQTT_SERVER, 1883, 60)
        self.client.loop_start() # 開啟背景線程持續監聽
  • 設定回呼函數(Callback)。loop_start() 非常關鍵,它讓 MQTT 在背景運作,不會卡住視窗的滑鼠點擊動作。

7. 事件處理函數

  • on_connect: 當連上伺服器時,指示燈轉為綠色,並訂閱溫濕度主題。

  • on_message: 當 ESP32 回傳數據時,解析數值,並呼叫 root.after(0, self.update_charts)

    注意:這裡使用 after 是為了確保繪圖動作是在視窗的主執行緒中完成,避免程式崩潰。

  • publish_led: 呼叫 client.publish() 將按鈕對應的字串送往 MQTT Broker。

8. 繪圖更新 update_charts

Python
    def update_charts(self):
        self.ax1.clear() # 清除舊圖
        # ... 繪製 Pie 圖 ...
        self.ax1.pie([self.temp_val, max(0.1, 50-self.temp_val)], ...)
        # ...
        self.canvas_plt.draw() # 刷新畫布顯示
  • 這是視覺化的核心。我們用 50-當前溫度100-當前濕度 來算出圓餅圖的剩餘比例。max(0.1, ...) 是為了防止數據超過範圍導致繪圖錯誤。


9. 主程式進入點

Python
if __name__ == "__main__":
    root = tk.Tk()      # 初始化視窗
    app = MQTTApp(root) # 啟動我們的程式邏輯
    root.mainloop()     # 保持視窗運行

這份程式展示了現代 IoT 應用的標準架構:硬體採集 (ESP32) -> 雲端中轉 (MQTT) -> 桌面監控 (Python)


VS code+Flutter (APK )


檔案路徑android/app/src/main/AndroidManifest.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
       
    <application
        android:label="flutter_application_1"
        android:name="${applicationName}"
        android:icon="@mipmap/ic_launcher"

        android:usesCleartextTraffic="true">


        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:launchMode="singleTop"
            android:theme="@style/LaunchTheme"
            android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
            android:hardwareAccelerated="true"
            android:windowSoftInputMode="adjustResize">
            <!-- Specifies an Android theme to apply to this Activity as soon as
                 the Android process has started. This theme is visible to the user
                 while the Flutter UI initializes. After that, this theme continues
                 to determine the Window background behind the Flutter UI. -->
            <meta-data
              android:name="io.flutter.embedding.android.NormalTheme"
              android:resource="@style/NormalTheme"
              />
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>
        <!-- Don't delete the meta-data below.
             This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
        <meta-data
            android:name="flutterEmbedding"
            android:value="2" />
    </application>
    <!-- Required to query activities that can process text, see:
         https://developer.android.com/training/package-visibility?hl=en and
         https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.

         In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
    <queries>
        <intent>
            <action android:name="android.intent.action.PROCESS_TEXT"/>
            <data android:mimeType="text/plain"/>
        </intent>
    </queries>
</manifest>

VS Code 中的 lib/main.dart 內容:

import 'package:flutter/material.dart';
import 'package:mqtt_client/mqtt_client.dart';
import 'package:mqtt_client/mqtt_server_client.dart';

// 修正 1:確保入口函式指向 MqttApp,並加上 const
void main() => runApp(const MqttApp());

class MqttApp extends StatelessWidget {
  // 修正 3:加入 const 建構子
  const MqttApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'ESP32 MQTT 控制面板',
      theme: ThemeData(
        useMaterial3: true,
        colorSchemeSeed: Colors.blue,
      ),
      // 修正 3:使用 const 改進效能
      home: const MqttControlPage(),
    );
  }
}

class MqttControlPage extends StatefulWidget {
  const MqttControlPage({super.key});

  @override
  State<MqttControlPage> createState() => _MqttControlPageState();
}

class _MqttControlPageState extends State<MqttControlPage> {
  // --- MQTT 配置 ---
  final String broker = 'mqttgo.io';
  final String topicTemp = 'alex9ufo/mqtt/ex1/Temp';
  final String topicHumi = 'alex9ufo/mqtt/ex1/Humi';
  final String topicLed = 'alex9ufo/mqtt/ex1/led';

  MqttServerClient? client;
  String temp = "--";
  String humi = "--";
  bool isConnected = false;

  @override
  void initState() {
    super.initState();
    _connect();
  }

  Future<void> _connect() async {
    // 修正:使用正確的屬性名稱 millisecondsSinceEpoch
    String clientId = 'flutter_client_${DateTime.now().millisecondsSinceEpoch}';
    client = MqttServerClient(broker, clientId);
    client!.port = 1883;
    client!.keepAlivePeriod = 20;

    client!.onConnected = () {
      if (mounted) setState(() => isConnected = true);
      debugPrint('MQTT 連線成功');
      client!.subscribe(topicTemp, MqttQos.atMostOnce);
      client!.subscribe(topicHumi, MqttQos.atMostOnce);
    };

    client!.onDisconnected = () {
      if (mounted) setState(() => isConnected = false);
      debugPrint('MQTT 斷開連線');
    };

    try {
      await client!.connect();
    } catch (e) {
      debugPrint("連線失敗: $e");
    }

    client!.updates!.listen((List<MqttReceivedMessage<MqttMessage>> c) {
      final MqttPublishMessage recMess = c[0].payload as MqttPublishMessage;
      final String payload = MqttPublishPayload.bytesToStringAsString(recMess.payload.message);

      if (mounted) {
        setState(() {
          if (c[0].topic == topicTemp) temp = payload;
          if (c[0].topic == topicHumi) humi = payload;
        });
      }
    });
  }

  // 發布控制指令
  void _sendCommand(String cmd) {
    if (isConnected) {
      final builder = MqttClientPayloadBuilder();
      builder.addString(cmd);
      client!.publishMessage(topicLed, MqttQos.atLeastOnce, builder.payload!);
      debugPrint("已發送指令: $cmd");
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("ESP32 遠端監控面板"),
        centerTitle: true,
        actions: [
          Padding(
            padding: const EdgeInsets.only(right: 15),
            child: CircleAvatar(
              radius: 8,
              backgroundColor: isConnected ? Colors.green : Colors.red,
            ),
          )
        ],
      ),
      body: SingleChildScrollView(
        child: Padding(
          padding: const EdgeInsets.all(20.0),
          child: Column(
            children: [
              // 溫濕度顯示卡片
              Row(
                children: [
                  _buildDataCard("溫度", "$temp°C", Colors.orange),
                  const SizedBox(width: 15),
                  _buildDataCard("濕度", "$humi%", Colors.blue),
                ],
              ),
              const SizedBox(height: 40),
              const Text("LED 模式控制", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
              const Divider(),
              const SizedBox(height: 20),
              // 控制按鈕組 (包含 Timer 與 Flash)
              _buildButtonGrid(),
            ],
          ),
        ),
      ),
    );
  }

  Widget _buildDataCard(String label, String value, Color color) {
    return Expanded(
      child: Container(
        padding: const EdgeInsets.all(20),
        decoration: BoxDecoration(
          color: color.withOpacity(0.1),
          borderRadius: BorderRadius.circular(15),
          border: Border.all(color: color),
        ),
        child: Column(
          children: [
            Text(label, style: TextStyle(color: color, fontWeight: FontWeight.bold)),
            Text(value, style: const TextStyle(fontSize: 26, fontWeight: FontWeight.bold)),
          ],
        ),
      ),
    );
  }

  Widget _buildButtonGrid() {
    return Column(
      children: [
        Row(
          children: [
            _actionBtn("亮燈 (ON)", "on", Colors.green),
            const SizedBox(width: 15),
            _actionBtn("滅燈 (OFF)", "off", Colors.blueGrey),
          ],
        ),
        const SizedBox(height: 15),
        Row(
          children: [
            _actionBtn("閃爍 (Flash)", "flash", Colors.orange),
            const SizedBox(width: 15),
            _actionBtn("定時 (Timer 5s)", "timer", Colors.purple),
          ],
        ),
      ],
    );
  }

  Widget _actionBtn(String label, String cmd, Color color) {
    return Expanded(
      child: SizedBox(
        height: 60,
        child: ElevatedButton(
          style: ElevatedButton.styleFrom(
            backgroundColor: color,
            foregroundColor: Colors.white,
            shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
          ),
          onPressed: isConnected ? () => _sendCommand(cmd) : null,
          child: Text(label, textAlign: TextAlign.center),
        ),
      ),
    );
  }
}

VS Code : TERMINAL 產生APK檔案

flutter clean

flutter pub get

flutter build apk --split-per-abi




D:\2026 RFID\2026-ex1\Flutter\EX1-1\flutter_application_1\build\app\outputs\flutter-apk\ 

app-arm64-v8a-release.apk 利用USB線 將NB的檔案 load到 Android 手機 內 安裝APP













沒有留言:

張貼留言

2026 作業3 RFID+ Telegram 練習

 2026 作業3  RFID+ Telegram  練習 (Wokwi 與 Telegram 二者溝通訊息反映比較慢 ) 歡迎 Alex 使用 RFID 控制系統 /on : 開啟 LED /off : 關閉 LED /flash : 閃爍模式 /timer : 開啟 5 秒 ...