2026年5月29日 星期五

IoT 智能控制面板 (Python Tkinter)

 IoT 智能控制面板  (Python Tkinter)

customtkinter

為了在 tkinter 中達到如同網頁般的圓角平滑陰影視覺效果,最頂級且優雅的作法是使用 CustomTkinter 庫。







安裝 Python MQTT 套件

 paho-mqtt   與  customtkinter


Python 與 Wokwi 的主題變數必須一字不差:

  • LED 控制:alex9ufo/home/led

  • 溫度:alex9ufo/home/temperature

  • 濕度:alex9ufo/home/humidity


Python程式

import customtkinter as ctk

import paho.mqtt.client as mqtt

import threading

import json


# --- 基礎視覺風格設定 ---

ctk.set_appearance_mode("Light")

ctk.set_default_color_theme("green")


# --- MQTT 參數設定(必須與 Wokwi 完全一致) ---

MQTT_BROKER = "broker.emqx.io"

MQTT_PORT = 1883

TOPIC_LED = "alex9ufo/home/led"

TOPIC_TEMP = "alex9ufo/home/temperature"

TOPIC_HUM = "alex9ufo/home/humidity"


class EnvironmentDashboard(ctk.CTk):

    def __init__(self):

        super().__init__()


        # 視窗設定

        self.title("IoT 智能控制面板 (Python MQTT 連動版)")

        self.geometry("540x420")

        self.configure(fg_color="#F5F5F7")


        # 建立 UI 介面

        self.setup_ui()


        # 啟動後台 MQTT 連線

        self.start_mqtt()


    def setup_ui(self):

        # 1. 頂部標題欄與狀態 (Header)

        self.header_frame = ctk.CTkFrame(self, fg_color="transparent")

        self.header_frame.pack(fill="x", padx=40, pady=(40, 20))


        self.title_label = ctk.CTkLabel(

            self.header_frame, text="環境主控台", 

            font=ctk.CTkFont(family="Arial", size=26, weight="bold"), text_color="#1D1D1F"

        )

        self.title_label.pack(side="left")


        self.status_frame = ctk.CTkFrame(self.header_frame, fg_color="transparent")

        self.status_frame.pack(side="right")


        self.status_dot = ctk.CTkLabel(self.status_frame, text="●", font=ctk.CTkFont(size=14), text_color="#FF3B30") # 預設紅色斷線

        self.status_dot.pack(side="left", padx=(0, 6))


        self.status_text = ctk.CTkLabel(self.status_frame, text="連線中...", font=ctk.CTkFont(family="Arial", size=14, weight="bold"), text_color="#86868B")

        self.status_text.pack(side="left")


        # 2. 數據卡片區域 (Grid Layout)

        self.grid_frame = ctk.CTkFrame(self, fg_color="transparent")

        self.grid_frame.pack(fill="x", padx=40, pady=10)

        self.grid_frame.grid_columnconfigure(0, weight=1)

        self.grid_frame.grid_columnconfigure(1, weight=1)


        # 🌡️ 溫度卡片

        self.temp_card = ctk.CTkFrame(self.grid_frame, fg_color="#FFFFFF", corner_radius=16)

        self.temp_card.grid(row=0, column=0, padx=(0, 10), sticky="nsew")

        self.temp_title = ctk.CTkLabel(self.temp_card, text="🌡️ 當前溫度", font=ctk.CTkFont(family="Arial", size=14, weight="bold"), text_color="#86868B")

        self.temp_title.pack(anchor="w", padx=20, pady=(20, 5))

        self.temp_value = ctk.CTkLabel(self.temp_card, text="--.- °C", font=ctk.CTkFont(family="Arial", size=28, weight="bold"), text_color="#1D1D1F")

        self.temp_value.pack(anchor="w", padx=20, pady=(0, 20))


        # 💧 濕度卡片

        self.hum_card = ctk.CTkFrame(self.grid_frame, fg_color="#FFFFFF", corner_radius=16)

        self.hum_card.grid(row=0, column=1, padx=(10, 0), sticky="nsew")

        self.hum_title = ctk.CTkLabel(self.hum_card, text="💧 當前濕度", font=ctk.CTkFont(family="Arial", size=14, weight="bold"), text_color="#86868B")

        self.hum_title.pack(anchor="w", padx=20, pady=(20, 5))

        self.hum_value = ctk.CTkLabel(self.hum_card, text="--.- %", font=ctk.CTkFont(family="Arial", size=28, weight="bold"), text_color="#1D1D1F")

        self.hum_value.pack(anchor="w", padx=20, pady=(0, 20))


        # 3. LED 控制卡片區域

        self.control_card = ctk.CTkFrame(self, fg_color="#FFFFFF", corner_radius=16)

        self.control_card.pack(fill="x", padx=40, pady=20)


        self.control_info_frame = ctk.CTkFrame(self.control_card, fg_color="transparent")

        self.control_info_frame.pack(side="left", padx=24, pady=20)


        self.control_title = ctk.CTkLabel(self.control_info_frame, text="遠端 LED 節點", font=ctk.CTkFont(family="Arial", size=18, weight="bold"), text_color="#1D1D1F")

        self.control_title.pack(anchor="w")


        self.control_status = ctk.CTkLabel(self.control_info_frame, text="目前狀態:關閉", font=ctk.CTkFont(family="Arial", size=13), text_color="#86868B")

        self.control_status.pack(anchor="w", pady=(2, 0))


        self.led_switch = ctk.CTkSwitch(

            self.control_card, text="", command=self.on_switch_toggle,

            onvalue="ON", offvalue="OFF", progress_color="#34C759"

        )

        self.led_switch.pack(side="right", padx=24)

        self.led_switch.configure(state="disabled") # 連線前禁用開關


    # --- MQTT 核心邏輯 ---

    def start_mqtt(self):

        # 初始化 paho-mqtt 客戶端

        self.client = mqtt.Client()

        self.client.on_connect = self.on_mqtt_connect

        self.client.on_message = self.on_mqtt_message

        self.client.on_disconnect = self.on_mqtt_disconnect


        # 使用獨立線程執行 MQTT 循環,避免造成 Tkinter 視窗凍結卡死

        mqtt_thread = threading.Thread(target=self.mqtt_loop_runner, daemon=True)

        mqtt_thread.start()


    def mqtt_loop_runner(self):

        try:

            self.client.connect(MQTT_BROKER, MQTT_PORT, 60)

            self.client.loop_forever() # 持續監聽

        except Exception as e:

            print(f"MQTT 連線失敗: {e}")


    def on_mqtt_connect(self, client, userdata, flags, rc):

        if rc == 0:

            print("Python 成功連線至 MQTT Broker!")

            # 切換 UI 狀態為已連線 (綠燈)

            self.status_dot.configure(text_color="#34C759")

            self.status_text.configure(text="已連線")

            self.led_switch.configure(state="normal")

            

            # 訂閱 Wokwi 的溫濕度主題

            self.client.subscribe(TOPIC_TEMP)

            self.client.subscribe(TOPIC_HUM)

        else:

            print(f"連線錯誤,錯誤碼: {rc}")


    def on_mqtt_disconnect(self, client, userdata, rc):

        print("與 MQTT Broker 斷開連線")

        self.status_dot.configure(text_color="#FF3B30")

        self.status_text.configure(text="已斷線")

        self.led_switch.configure(state="disabled")


    def on_mqtt_message(self, client, userdata, msg):

        topic = msg.topic

        payload = msg.payload.decode("utf-8")

        print(f"收到 Wokwi 數據 -> 主題: {topic} | 內容: {payload}")


        # 即時更新 Tkinter 介面上的數字

        if topic == TOPIC_TEMP:

            self.temp_value.configure(text=f"{float(payload):.1f} °C")

        elif topic == TOPIC_HUM:

            self.hum_value.configure(text=f"{float(payload):.1f} %")


    def on_switch_toggle(self):

        # 取得目前開關狀態 (ON / OFF)

        command = self.led_switch.get()

        self.control_status.configure(text=f"目前狀態:{'開啟' if command == 'ON' else '關閉'}")

        

        # 發送指令給 Wokwi

        self.client.publish(TOPIC_LED, command)

        print(f"已發送控制指令給 Wokwi -> {command}")


if __name__ == "__main__":

    app = EnvironmentDashboard()

    app.mainloop()




#include <WiFi.h>
#include <PubSubClient.h>
#include <DHTesp.h>  // 使用 DHTesp 函式庫



// --- 網路與 MQTT 伺服器設定 ---
const char* ssid = "Wokwi-GUEST"; // Wokwi 專用虛擬 WiFi
const char* password = "";
const char* mqtt_server = "broker.emqx.io";
const int mqtt_port = 1883;

// 主題 (Topic) 設定 - 建議加上個人化後綴避免與他人衝突
//const char* topic_led = "alex9ufo/esp32/led";
//const char* topic_temp = "alex9ufo/esp32/temp";
//const char* topic_hum = "alex9ufo/esp32/hum";


// 定義 MQTT 主題 (已更新為 alex9ufo 專屬代稱)
const char* topic_led = "alex9ufo/home/led";
const char* topic_temp = "alex9ufo/home/temperature";
const char* topic_hum = "alex9ufo/home/humidity";

#define LED_PIN 2
#define DHTPIN 15

DHTesp dht;          // 宣告 DHTesp 物件
WiFiClient espClient;
PubSubClient client(espClient);

unsigned long lastMsg = 0;

void setup_wifi() {
  delay(10);
  Serial.print("Connecting to ");
  Serial.println(ssid);
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println("\nWiFi connected");
}

// 接收來自 EasyBuilder Pro 的指令
void callback(char* topic, byte* payload, unsigned length) {
  Serial.print("Message arrived [");
  Serial.print(topic);
  Serial.print("] ");
 
  String message;
  for (int i = 0; i < length; i++) {
    message += (char)payload[i];
  }
  Serial.println(message);

  if (String(topic) == topic_led) {
    if (message == "ON") {
      digitalWrite(LED_PIN, HIGH);
    } else if (message == "OFF") {
      digitalWrite(LED_PIN, LOW);
    }
  }
}

void reconnect() {
  while (!client.connected()) {
    Serial.print("Attempting MQTT connection...");
    // 隨機生成 Client ID 避免與他人衝突導致頻繁斷線
    String clientId = "ESP32Client-" + String(random(0, 0xffff), HEX);
    if (client.connect(clientId.c_str())) {
      Serial.println("connected");
      client.subscribe(topic_led); // 訂閱 LED 控制主題
    } else {
      Serial.print("failed, rc=");
      Serial.print(client.state());
      Serial.println(" try again in 5 seconds");
      delay(5000);
    }
  }
}

void setup() {
  pinMode(LED_PIN, OUTPUT);
  Serial.begin(115200);
  setup_wifi();
  client.setServer(mqtt_server, mqtt_port);
  client.setCallback(callback);
 
  // 修正:DHTesp 的初始化寫法
  dht.setup(DHTPIN, DHTesp::DHT22);
}

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

  // 每 2 秒發送一次溫濕度
  unsigned long now = millis();
  if (now - lastMsg > 2000) {
    lastMsg = now;
   
    // 修正:DHTesp 讀取溫濕度的寫法
    float h = dht.getHumidity();
    float t = dht.getTemperature();

    if (!isnan(h) && !isnan(t)) {
      client.publish(topic_temp, String(t, 1).c_str()); // 格式化為小數點後1位
      client.publish(topic_hum, String(h, 1).c_str());
     
      Serial.print("Temp: "); Serial.print(t, 1);
      Serial.print("°C | Hum: "); Serial.print(h, 1); Serial.println("%");
    } else {
      Serial.println("Failed to read from DHT sensor!");
    }
  }
}


IoT 智能控制面板

 IoT 智能控制面板





測試你的成果!

  1. 啟動 Wokwi 模擬: 點擊 Wokwi 的播放鍵,觀察序列埠(Serial Monitor),確認它成功連上 Wokwi-GUEST WiFi 並且顯示 嘗試 MQTT 連線...已連線!

  2. 開啟網頁: 在電腦上雙擊打開修改好的 index.html

  3. 測試控制: 點擊網頁上的 LED 開關,你會看見 Wokwi 視窗裡的 ESP32 藍色燈(或外接 LED)同步亮起或熄滅!

  4. 測試接收: 在 Wokwi 中點擊 DHT22 元件,手動拉動滑桿改變溫度或濕度,約 5 秒內,你網頁上原本寫死的 Figma 數字就會即時更新。

這個架構就是現代智慧家居(如 Home Assistant)或工業物聯網(IIoT)的核心基礎。

網頁程式碼 (index.html)

請將以下內容完整複製,儲存為 index.html,直接雙擊檔案用瀏覽器開啟即可:

<!DOCTYPE html>

<html lang="zh-TW">

<head>

    <meta charset="UTF-8">

    <meta name="viewport" content="width=device-width, initial-scale=1.0">

    <title>IoT 智能控制面板 (Figma 實作版)</title>

    <style>

        /* 模擬 Figma 的基礎畫布與字型設定 */

        :root {

            --bg-color: #F5F5F7;

            --card-bg: #FFFFFF;

            --text-main: #1D1D1F;

            --text-sub: #86868B;

            --primary-green: #34C759;

            --alert-red: #FF3B30;

        }


        body {

            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;

            background-color: var(--bg-color);

            color: var(--text-main);

            margin: 0;

            padding: 40px 20px;

            display: flex;

            justify-content: center;

            align-items: center;

            min-height: 100vh;

            box-sizing: border-box;

        }


        /* 面板主外殼 (Figma Frame) */

        .dashboard {

            width: 100%;

            max-width: 480px;

            background: var(--card-bg);

            border-radius: 24px;

            padding: 32px;

            box-shadow: 0px 10px 30px rgba(0, 0, 0, 0.05);

        }


        /* 標題欄與連線狀態 */

        .header {

            display: flex;

            justify-content: space-between;

            align-items: center;

            margin-bottom: 32px;

        }


        .header h1 {

            font-size: 24px;

            font-weight: 600;

            margin: 0;

        }


        .status-badge {

            display: flex;

            align-items: center;

            font-size: 14px;

            color: var(--text-sub);

            font-weight: 500;

        }


        .status-dot {

            width: 8px;

            height: 8px;

            background-color: var(--alert-red);

            border-radius: 50%;

            margin-right: 8px;

            transition: background-color 0.3s ease;

        }


        .status-dot.connected {

            background-color: var(--primary-green);

        }


        /* 數據卡片佈局 (Grid) */

        .grid-container {

            display: grid;

            grid-template-columns: 1fr 1fr;

            gap: 16px;

            margin-bottom: 24px;

        }


        .card {

            background: var(--bg-color);

            border-radius: 16px;

            padding: 20px;

            display: flex;

            flex-direction: column;

        }


        .card-title {

            font-size: 14px;

            color: var(--text-sub);

            margin-bottom: 8px;

            font-weight: 500;

        }


        .card-value {

            font-size: 28px;

            font-weight: 700;

            margin: 0;

        }


        /* 控制卡片 (全寬) */

        .control-card {

            background: var(--bg-color);

            border-radius: 16px;

            padding: 24px;

            display: flex;

            justify-content: space-between;

            align-items: center;

        }


        .control-info h3 {

            margin: 0 0 4px 0;

            font-size: 18px;

            font-weight: 600;

        }


        .control-info p {

            margin: 0;

            font-size: 13px;

            color: var(--text-sub);

        }


        /* Figma 風格的 Toggle Switch 開關 */

        .switch {

            position: relative;

            display: inline-block;

            width: 60px;

            height: 34px;

        }


        .switch input {

            opacity: 0;

            width: 0;

            height: 0;

        }


        .slider {

            position: absolute;

            cursor: pointer;

            top: 0; left: 0; right: 0; bottom: 0;

            background-color: #D1D1D6;

            transition: .3s cubic-bezier(0.4, 0, 0.2, 1);

            border-radius: 34px;

        }


        .slider:before {

            position: absolute;

            content: "";

            height: 26px;

            width: 26px;

            left: 4px;

            bottom: 4px;

            background-color: white;

            transition: .3s cubic-bezier(0.4, 0, 0.2, 1);

            border-radius: 50%;

            box-shadow: 0px 3px 8px rgba(0, 0, 0, 0.15);

        }


        input:checked + .slider {

            background-color: var(--primary-green);

        }


        input:checked + .slider:before {

            transform: translateX(26px);

        }

    </style>

</head>

<body>


    <div class="dashboard">

        <!-- 頂部狀態 -->

        <div class="header">

            <h1>環境主控台</h1>

            <div class="status-badge">

                <span id="status-dot" class="status-dot"></span>

                <span id="status-text">連線中...</span>

            </div>

        </div>


        <!-- DHT22 溫濕度顯示區 -->

        <div class="grid-container">

            <div class="card">

                <span class="card-title">🌡️ 當前溫度</span>

                <p id="temp-text" class="card-value">--.- °C</p>

            </div>

            <div class="card">

                <span class="card-title">💧 當前濕度</span>

                <p id="hum-text" class="card-value">--.- %</p>

            </div>

        </div>


        <!-- LED 控制區 -->

        <div class="control-card">

            <div class="control-info">

                <h3>遠端 LED 節點</h3>

                <p id="led-status-text">目前狀態:關閉</p>

            </div>

            <label class="switch">

                <input type="checkbox" id="led-toggle" disabled>

                <span class="slider"></span>

            </label>

        </div>

    </div>


    <!-- 核心:引入 MQTT.js 函式庫 -->

    <script src="https://unpkg.com/mqtt/dist/mqtt.min.js"></script>

    <script>

        /*****************************************

         * 1. 參數設定 (已更新為 alex9ufo 專屬代稱)

         *****************************************/

        const MQTT_BROKER = 'wss://broker.emqx.io:8084/mqtt';

        const TOPIC_LED  = 'alex9ufo/home/led';

        const TOPIC_TEMP = 'alex9ufo/home/temperature';

        const TOPIC_HUM  = 'alex9ufo/home/humidity';


        // 取得 DOM 元件

        const statusDot = document.getElementById('status-dot');

        const statusText = document.getElementById('status-text');

        const tempText = document.getElementById('temp-text');

        const humText = document.getElementById('hum-text');

        const ledToggle = document.getElementById('led-toggle');

        const ledStatusText = document.getElementById('led-status-text');


        /*****************************************

         * 2. 初始化 MQTT 連線

         *****************************************/

        console.log('正在嘗試連線至 MQTT Broker...');

        const client = mqtt.connect(MQTT_BROKER);


        // 連線成功事件

        client.on('connect', () => {

            console.log('成功連線至 EMQX Broker!');

            statusDot.classList.add('connected');

            statusText.innerText = '已連線';

            ledToggle.disabled = false; // 開放按鈕操控


            // 訂閱來自 Wokwi ESP32 的數據主題

            client.subscribe([TOPIC_TEMP, TOPIC_HUM], (err) => {

                if (!err) {

                    console.log('已成功訂閱溫濕度主題');

                }

            });

        });


        // 連線斷開事件

        client.on('close', () => {

            console.log('與 Broker 連線斷開');

            statusDot.classList.remove('connected');

            statusText.innerText = '連線斷開';

            ledToggle.disabled = true;

        });


        /*****************************************

         * 3. 接收數據 (接收從 Wokwi 傳來的 DHT22 資料)

         *****************************************/

        client.on('message', (topic, message) => {

            const rawData = message.toString();

            console.log(`收到主題 [${topic}]: ${rawData}`);


            if (topic === TOPIC_TEMP) {

                // 更新溫度

                tempText.innerText = parseFloat(rawData).toFixed(1) + " °C";

            } else if (topic === TOPIC_HUM) {

                // 更新濕度

                humText.innerText = parseFloat(rawData).toFixed(1) + " %";

            }

        });


        /*****************************************

         * 4. 發送控制 (點擊網頁開關時傳送給 Wokwi)

         *****************************************/

        ledToggle.addEventListener('change', (event) => {

            const command = event.target.checked ? 'ON' : 'OFF';

            

            client.publish(TOPIC_LED, command, { qos: 1 }, (err) => {

                if (!err) {

                    console.log(`指令 [${command}] 已成功發送至主題: ${TOPIC_LED}`);

                    ledStatusText.innerText = `目前狀態:${command === 'ON' ? '開啟' : '關閉'}`;

                } else {

                    console.error('指令發送失敗:', err);

                }

            });

        });

    </script>

</body>

</html>




#include <WiFi.h>
#include <PubSubClient.h>
#include <DHTesp.h>  // 使用 DHTesp 函式庫



// --- 網路與 MQTT 伺服器設定 ---
const char* ssid = "Wokwi-GUEST"; // Wokwi 專用虛擬 WiFi
const char* password = "";
const char* mqtt_server = "broker.emqx.io";
const int mqtt_port = 1883;

// 主題 (Topic) 設定 - 建議加上個人化後綴避免與他人衝突
//const char* topic_led = "alex9ufo/esp32/led";
//const char* topic_temp = "alex9ufo/esp32/temp";
//const char* topic_hum = "alex9ufo/esp32/hum";


// 定義 MQTT 主題 (已更新為 alex9ufo 專屬代稱)
const char* topic_led = "alex9ufo/home/led";
const char* topic_temp = "alex9ufo/home/temperature";
const char* topic_hum = "alex9ufo/home/humidity";

#define LED_PIN 2
#define DHTPIN 15

DHTesp dht;          // 宣告 DHTesp 物件
WiFiClient espClient;
PubSubClient client(espClient);

unsigned long lastMsg = 0;

void setup_wifi() {
  delay(10);
  Serial.print("Connecting to ");
  Serial.println(ssid);
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println("\nWiFi connected");
}

// 接收來自 EasyBuilder Pro 的指令
void callback(char* topic, byte* payload, unsigned length) {
  Serial.print("Message arrived [");
  Serial.print(topic);
  Serial.print("] ");
 
  String message;
  for (int i = 0; i < length; i++) {
    message += (char)payload[i];
  }
  Serial.println(message);

  if (String(topic) == topic_led) {
    if (message == "ON") {
      digitalWrite(LED_PIN, HIGH);
    } else if (message == "OFF") {
      digitalWrite(LED_PIN, LOW);
    }
  }
}

void reconnect() {
  while (!client.connected()) {
    Serial.print("Attempting MQTT connection...");
    // 隨機生成 Client ID 避免與他人衝突導致頻繁斷線
    String clientId = "ESP32Client-" + String(random(0, 0xffff), HEX);
    if (client.connect(clientId.c_str())) {
      Serial.println("connected");
      client.subscribe(topic_led); // 訂閱 LED 控制主題
    } else {
      Serial.print("failed, rc=");
      Serial.print(client.state());
      Serial.println(" try again in 5 seconds");
      delay(5000);
    }
  }
}

void setup() {
  pinMode(LED_PIN, OUTPUT);
  Serial.begin(115200);
  setup_wifi();
  client.setServer(mqtt_server, mqtt_port);
  client.setCallback(callback);
 
  // 修正:DHTesp 的初始化寫法
  dht.setup(DHTPIN, DHTesp::DHT22);
}

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

  // 每 2 秒發送一次溫濕度
  unsigned long now = millis();
  if (now - lastMsg > 2000) {
    lastMsg = now;
   
    // 修正:DHTesp 讀取溫濕度的寫法
    float h = dht.getHumidity();
    float t = dht.getTemperature();

    if (!isnan(h) && !isnan(t)) {
      client.publish(topic_temp, String(t, 1).c_str()); // 格式化為小數點後1位
      client.publish(topic_hum, String(h, 1).c_str());
     
      Serial.print("Temp: "); Serial.print(t, 1);
      Serial.print("°C | Hum: "); Serial.print(h, 1); Serial.println("%");
    } else {
      Serial.println("Failed to read from DHT sensor!");
    }
  }
}


程式碼是一個典型的 ESP32 物聯網(IoT)應用程式,運行於 Wokwi 模擬器環境中。它的核心功能是透過 Wi-Fi 連接到公共的 MQTT Broker (EMQX),將 DHT22 感測器量測到的溫度與濕度定時發送(Publish)出去,同時訂閱(Subscribe)控制主題,接收來自網頁端(Figma UI)的指令來切換 LED 的開關。

由於你傳送的文字中,程式碼重複貼了兩次,以下我將針對這份結構完整的程式碼進行逐行詳細解說

📑 1. 引入函式庫與定義全域變數

C++
#include <WiFi.h>
#include <PubSubClient.h>
#include <DHTesp.h>  // 使用 DHTesp 函式庫
  • #include <WiFi.h>:引入 ESP32 的 Wi-Fi 核心函式庫,負責處理無線網路連線。

  • #include <PubSubClient.h>:引入 MQTT 協定的函式庫,讓 ESP32 具備發布(Publish)與訂閱(Subscribe)訊息的功能。

  • #include <DHTesp.h>:引入專為 ESP32 優化的 DHT 溫濕度感測器函式庫。

C++
// --- 網路與 MQTT 伺服器設定 ---
const char* ssid = "Wokwi-GUEST"; // Wokwi 專用虛擬 WiFi
const char* password = "";
const char* mqtt_server = "broker.emqx.io";
const int mqtt_port = 1883;
  • ssid / password:設定 Wi-Fi 的帳號與密碼。Wokwi-GUEST 是 Wokwi 模擬環境提供的專用虛擬基地台,不需密碼。

  • mqtt_server:設定 MQTT 伺服器(Broker)的網址,這裡使用的是 EMQX 提供的免費公共測試伺服器。

  • mqtt_port:設定 MQTT 的通訊埠,標準非加密的 MQTT 服務埠號為 1883

C++
// 定義 MQTT 主題 (已更新為 alex9ufo 專屬代稱)
const char* topic_led = "alex9ufo/home/led";
const char* topic_temp = "alex9ufo/home/temperature";
const char* topic_hum = "alex9ufo/home/humidity";
  • 這三行定義了通訊的「頻道名稱」(Topic)。網頁前端與硬體端必須對應相同的名稱才能互通:

    • topic_led:接收網頁端傳來控制 LED 開關的頻道。

    • topic_temp:發送溫度數據的頻道。

    • topic_hum:發送濕度數據的頻道。

C++
#define LED_PIN 2
#define DHTPIN 15
  • 使用巨集定義硬體腳位。將 ESP32 的 GPIO 2 定義為 LED 控制腳(通常也是內建藍色 LED 腳位),將 GPIO 15 定義為 DHT22 的數據訊號腳。

C++
DHTesp dht;          // 宣告 DHTesp 物件
WiFiClient espClient;
PubSubClient client(espClient);
unsigned long lastMsg = 0;
  • DHTesp dht;:實例化一個溫濕度感測器物件。

  • WiFiClient espClient;:建立一個網路用戶端物件,負責底層的 TCP 連線。

  • PubSubClient client(espClient);:建立 MQTT 用戶端物件,並將剛才的 Wi-Fi 連線傳入,讓 MQTT 透過 Wi-Fi 傳輸資料。

  • lastMsg:宣告一個無號長整型變數,用來記錄上一次發送資料的時間(單位為毫秒),實現非阻塞式的定時器。

🌐 2. Wi-Fi 連線初始化函式

C++
void setup_wifi() {
  delay(10);
  Serial.print("Connecting to ");
  Serial.println(ssid);
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println("\nWiFi connected");
}
  • WiFi.begin(ssid, password);:啟動 ESP32 的 Wi-Fi 晶片並嘗試連線到指定的基地台。

  • while (WiFi.status() != WL_CONNECTED):使用迴圈檢查連線狀態。如果尚未連線成功,程式會每隔 0.5 秒(delay(500))在序列埠監控視窗印出一個點(.),直到成功連上為止。

📩 3. MQTT 訊息接收回呼函式(Callback)

當網頁端(Figma UI)發送指令到 ESP32 訂閱的 alex9ufo/home/led 主題時,MQTT 伺服器會將訊息推播過來,自動觸發此函式:

C++
void callback(char* topic, byte* payload, unsigned length) {
  Serial.print("Message arrived [");
  Serial.print(topic);
  Serial.print("] ");
  
  String message;
  for (int i = 0; i < length; i++) {
    message += (char)payload[i];
  }
  Serial.println(message);
  • 參數說明topic 是接收到訊息的主題名稱;payload 是傳過來的原始資料(位元組陣列);length 是資料長度。

  • for (int i = 0; i < length; i++):因為傳進來的資料是 byte 陣列,所以透過迴圈逐一將字元(char)累加到字串變數 message 中,轉換成看得懂的文字(例如 "ON" 或 "OFF")。

C++
  if (String(topic) == topic_led) {
    if (message == "ON") {
      digitalWrite(LED_PIN, HIGH);
    } else if (message == "OFF") {
      digitalWrite(LED_PIN, LOW);
    }
  }
}
  • if (String(topic) == topic_led):確認收到的訊息確實是來自控制 LED 的頻道。

  • 控制邏輯:如果文字內容為 "ON",執行 digitalWrite(LED_PIN, HIGH) 將 GPIO 2 輸出高電位,點亮 LED;若為 "OFF",則輸出低電位,熄滅 LED。

🔄 4. MQTT 斷線重連函式

C++
void reconnect() {
  while (!client.connected()) {
    Serial.print("Attempting MQTT connection...");
    // 隨機生成 Client ID 避免與他人衝突導致頻繁斷線
    String clientId = "ESP32Client-" + String(random(0, 0xffff), HEX);
  • while (!client.connected()):如果與 MQTT 伺服器斷開連線,就會進入這個迴圈嘗試重連。

  • clientId:公共 MQTT 伺服器規定每個客戶端的 Client ID 必須唯一。這裡利用 random(0, 0xffff) 產生隨機數並轉成十六進位字串,避免你的 ESP32 與其他人的裝置撞名而被剔除連線。

C++
    if (client.connect(clientId.c_str())) {
      Serial.println("connected");
      client.subscribe(topic_led); // 訂閱 LED 控制主題
    } else {
      Serial.print("failed, rc=");
      Serial.print(client.state());
      Serial.println(" try again in 5 seconds");
      delay(5000);
    }
  }
}
  • client.connect(...):嘗試向 MQTT 伺服器註冊並連線。

  • 連線成功:印出 connected,並立刻執行 client.subscribe(topic_led),告訴伺服器「我要監聽 LED 控制頻道,有新訊息請發給我」。

  • 連線失敗:印出錯誤代碼(rc),並等待 5 秒後重新進入迴圈嘗試。

⚙️ 5. 硬體與初始化設定(Setup)

C++
void setup() {
  pinMode(LED_PIN, OUTPUT);
  Serial.begin(115200);
  setup_wifi();
  client.setServer(mqtt_server, mqtt_port);
  client.setCallback(callback);
  
  // 修正:DHTesp 的初始化寫法
  dht.setup(DHTPIN, DHTesp::DHT22); 
}
  • pinMode(LED_PIN, OUTPUT);:將 GPIO 2 設定為輸出模式。

  • Serial.begin(115200);:初始化序列埠通訊,波特率設定為 115200,用於在電腦畫面上印出除錯訊息。

  • setup_wifi();:呼叫前面寫好的函式連接無線網路。

  • client.setServer(...):綁定 MQTT 伺服器位址與通訊埠。

  • client.setCallback(callback);:向 MQTT 庫註冊回呼函式,指定當收到訊息時,由 callback 函式來處理。

  • dht.setup(...):這是 DHTesp 函式庫特有的初始化方式,指定控制腳位為 15,感測器型態為 DHT22

🔄 6. 主程式循環(Loop)

C++
void loop() {
  if (!client.connected()) {
    reconnect();
  }
  client.loop();
  • if (!client.connected()):每次循環都檢查 MQTT 連線狀態,斷線就呼叫 reconnect()

  • client.loop();:這是 MQTT 庫的核心函式,必須在 loop() 裡頻繁執行。它負責維持與伺服器的連線心跳(KeepAlive),並檢查是否有訂閱的訊息送達。

C++
  // 每 2 秒發送一次溫濕度
  unsigned long now = millis();
  if (now - lastMsg > 2000) {
    lastMsg = now;
    
    // 修正:DHTesp 讀取溫濕度的寫法
    float h = dht.getHumidity();
    float t = dht.getTemperature();
  • 時間控制millis() 會回傳系統開機至今的毫秒數。利用 now - lastMsg > 2000 來判斷是否過去了 2 秒。這種做法比使用 delay(2000) 更好,因為它不會讓晶片暫停運作,確保 MQTT 訊息接收不會卡頓。

  • 讀取數據:呼叫 dht.getHumidity() 取得濕度,dht.getTemperature() 取得溫度。

C++
    if (!isnan(h) && !isnan(t)) {
      client.publish(topic_temp, String(t, 1).c_str()); // 格式化為小數點後1位
      client.publish(topic_hum, String(h, 1).c_str());
      
      Serial.print("Temp: "); Serial.print(t, 1);
      Serial.print("°C | Hum: "); Serial.print(h, 1); Serial.println("%");
    } else {
      Serial.println("Failed to read from DHT sensor!");
    }
  }
}
  • !isnan(h) && !isnan(t)isnan 代表 "Is Not a Number"。此處用來確認讀取出來的溫濕度數值是有效的,而非空值或錯誤訊號。

  • client.publish(...):將數值轉成字串(String(t, 1) 代表保留小數點後 1 位),然後轉成 C 語言格式字串(.c_str())發布到對應的 MQTT 頻道(topic_temp / topic_hum),網頁端便能即時接收並顯示。

  • 異常處理:若讀取失敗,則在序列埠印出錯誤訊息。

IoT 智能控制面板 (Python Tkinter)

  IoT 智能控制面板  (Python Tkinter) customtkinter 為了在 tkinter 中達到如同網頁般的 圓角 與 平滑陰影 視覺效果,最頂級且優雅的作法是使用 CustomTkinter 庫。 安裝 Python MQTT 套件  paho-mq...