2026年9月11日 星期五

2026-09 MQTT基本觀念(作業1)

 2026-09  MQTT基本觀念(作業1)


將 執行結果 畫面 錄製約 3分鐘 以上
1) MQTT BOX 動作畫面 
2) Python + TKinter 動作畫面 
3) Wokwi ESP32 動作畫面 
並上傳YT (公開) mail alex9ufo@gmail.com






MQTT(Message Queuing Telemetry Transport)是一種輕量級、基於發布/訂閱(Publish/Subscribe)架構的網路傳輸協定,非常適合硬體資源有限、網路頻寬低的物聯網(IoT)應用場景。
以下為您整理 MQTT 的核心觀念,並提供一個可以使用電腦立即測試的快速實驗

核心觀念
MQTT 的核心思維是「解耦」,傳送訊息與接收訊息的裝置不需要知道彼此是誰,完全透過一個中間人來協調。

  • Broker(代理伺服器):MQTT 的核心中樞。負責接收發布者送來的訊息,並將訊息過濾、分發給有訂閱該主題的接收者。
  • Publisher(發布者):發送數據的客戶端(例如:溫度感測器)。它會將訊息貼上特定的「主題」標籤後送往 Broker。
  • Subscriber(訂閱者):接收數據的客戶端(例如:手機 App、伺服器)。它向 Broker 訂閱特定的「主題」,當該主題有新訊息時就會收到通知。
  • Topic(主題):訊息的分類標籤,採用斜線階層設計(例如:home/livingroom/temperature)。發布者與訂閱者全靠主題來對接訊息。
  • 發布/訂閱模型(Publish/Subscribe):發布者與訂閱者彼此不直接相連,雙方透過 Topic(主題) 來分類訊息。訂閱特定主題的人,就會收到該主題的最新資料。 [1]
  • 輕量與省電:標頭檔極小(最小僅 2 位元組),非常適合頻寬低、延遲高或不穩定的網路環境。 [1, 2]
  • QoS(Quality of Service,服務品質):提供三種訊息傳遞等級,確保資料送達:
    • QoS 0:最多送達一次(可能會遺失)。
    • QoS 1:至少送達一次(可能重複)。
    • QoS 2:確保僅送達一次(最安全但耗時)。
  • 參考' https://resource.webduino.io/blog/mqtt-guide 

  • 參考 https://alex9ufoexploer.blogspot.com/2026/03/2026-1-mqtt.html

WOKWI 程式






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

// --- 網路與 MQTT 設定 ---
const char* ssid        = "Wokwi-GUEST";
const char* password    = "";
const char* mqtt_server = "mqttgo.io";
const int   mqtt_port   = 1883;

// --- MQTT 主題設定 ---
const char* TOPIC_TEMP   = "alex9ufo/temp";
const char* TOPIC_HUMI   = "alex9ufo/humi";
const char* TOPIC_LED    = "alex9ufo/led";
const char* TOPIC_STATUS = "alex9ufo/status";

// --- 硬體腳位定義 (精確對照圖形) ---
#define DHTPIN 4          // DHT22 接在 GPIO 4
#define DHTTYPE DHT22

#define RELAY1_PIN 27     // Relay 1 接 GPIO 27
#define RELAY2_PIN 14     // Relay 2 接 GPIO 14
#define RELAY3_PIN 12     // Relay 3 接 GPIO 12
#define RELAY4_PIN 13     // Relay 4 接 GPIO 13

// --- 元件初始化 ---
DHT dht(DHTPIN, DHTTYPE);
LiquidCrystal_I2C lcd(0x27, 16, 2); // Wokwi 預設 I2C 位址為 0x27

WiFiClient espClient;
PubSubClient client(espClient);

unsigned long lastMsg = 0;
float currentTemp = 0.0;
float currentHumi = 0.0;

// LCD 更新顯示函式
void updateLCD() {
  lcd.setCursor(0, 0);
  lcd.print("Temp: ");
  lcd.print(currentTemp, 1);
  lcd.print((char)223); // 顯示 ° 符號
  lcd.print("C   ");

  lcd.setCursor(0, 1);
  lcd.print("Humi: ");
  lcd.print(currentHumi, 1);
  lcd.print(" %   ");
}

// Wi-Fi 連線設定
void setup_wifi() {
  delay(10);
  Serial.println();
  Serial.print("Connecting to ");
  Serial.println(ssid);

  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid, password);

  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }

  Serial.println("\nWiFi connected!");
  Serial.print("IP address: ");
  Serial.println(WiFi.localIP());
}

// MQTT 接收控制指令處理 (Callback)
void callback(char* topic, byte* payload, unsigned int length) {
  String message = "";
  for (int i = 0; i < length; i++) {
    message += (char)payload[i];
  }
  message.trim();
  Serial.print("MQTT Command Arrival [");
  Serial.print(topic);
  Serial.print("]: ");
  Serial.println(message);

  // 指令判斷與繼電器控制 (HIGH = 觸發點亮 LED)
  if (message == "1on") {
    digitalWrite(RELAY1_PIN, HIGH);
    client.publish(TOPIC_STATUS, "1on");
  } else if (message == "1off") {
    digitalWrite(RELAY1_PIN, LOW);
    client.publish(TOPIC_STATUS, "1off");
  } else if (message == "2on") {
    digitalWrite(RELAY2_PIN, HIGH);
    client.publish(TOPIC_STATUS, "2on");
  } else if (message == "2off") {
    digitalWrite(RELAY2_PIN, LOW);
    client.publish(TOPIC_STATUS, "2off");
  } else if (message == "3on") {
    digitalWrite(RELAY3_PIN, HIGH);
    client.publish(TOPIC_STATUS, "3on");
  } else if (message == "3off") {
    digitalWrite(RELAY3_PIN, LOW);
    client.publish(TOPIC_STATUS, "3off");
  } else if (message == "4on") {
    digitalWrite(RELAY4_PIN, HIGH);
    client.publish(TOPIC_STATUS, "4on");
  } else if (message == "4off") {
    digitalWrite(RELAY4_PIN, LOW);
    client.publish(TOPIC_STATUS, "4off");
  } else if (message == "allon") {
    digitalWrite(RELAY1_PIN, HIGH);
    digitalWrite(RELAY2_PIN, HIGH);
    digitalWrite(RELAY3_PIN, HIGH);
    digitalWrite(RELAY4_PIN, HIGH);
    client.publish(TOPIC_STATUS, "allon");
  } else if (message == "alloff") {
    digitalWrite(RELAY1_PIN, LOW);
    digitalWrite(RELAY2_PIN, LOW);
    digitalWrite(RELAY3_PIN, LOW);
    digitalWrite(RELAY4_PIN, LOW);
    client.publish(TOPIC_STATUS, "alloff");
  }
}

// MQTT 自動重連機制
void reconnect() {
  while (!client.connected()) {
    Serial.print("Attempting MQTT connection...");
    String clientId = "ESP32Client-";
    clientId += String(random(0xffff), HEX);

    if (client.connect(clientId.c_str())) {
      Serial.println("connected");
      client.subscribe(TOPIC_LED);
    } else {
      Serial.print("failed, rc=");
      Serial.print(client.state());
      Serial.println(" try again in 5 seconds");
      delay(5000);
    }
  }
}

void setup() {
  Serial.begin(115200);

  // 初始化 LCD (GPIO 21=SDA, GPIO 22=SCL)
  Wire.begin(21, 22);
  lcd.init();
  lcd.backlight();
  lcd.setCursor(0, 0);
  lcd.print("Connecting WiFi...");

  // 初始化繼電器腳位
  pinMode(RELAY1_PIN, OUTPUT);
  pinMode(RELAY2_PIN, OUTPUT);
  pinMode(RELAY3_PIN, OUTPUT);
  pinMode(RELAY4_PIN, OUTPUT);

  // 預設關閉所有繼電器
  digitalWrite(RELAY1_PIN, LOW);
  digitalWrite(RELAY2_PIN, LOW);
  digitalWrite(RELAY3_PIN, LOW);
  digitalWrite(RELAY4_PIN, LOW);

  // 初始化 DHT 感測器與 Wi-Fi
  dht.begin();
  setup_wifi();

  lcd.clear();
  lcd.print("WiFi Connected!");
  delay(1000);
  lcd.clear();

  client.setServer(mqtt_server, mqtt_port);
  client.setCallback(callback);
}

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

  // 每 2 秒讀取感測器、更新 LCD 與發行 MQTT 數據
  unsigned long now = millis();
  if (now - lastMsg > 2000) {
    lastMsg = now;

    float h = dht.readHumidity();
    float t = dht.readTemperature();

    if (!isnan(h) && !isnan(t)) {
      currentTemp = t;
      currentHumi = h;

      // 發行至 MQTT
      char tempStr[8], humiStr[8];
      dtostrf(t, 1, 2, tempStr);
      dtostrf(h, 1, 2, humiStr);

      client.publish(TOPIC_TEMP, tempStr);
      client.publish(TOPIC_HUMI, humiStr);

      // 更新 16x2 LCD
      updateLCD();

      Serial.print("Temp: "); Serial.print(tempStr);
      Serial.print(" C | Humi: "); Serial.print(humiStr); Serial.println(" %");
    } else {
      Serial.println("Failed to read from DHT sensor!");
    }
  }
}




Python 程式


import tkinter as tk
from tkinter import ttk
import paho.mqtt.client as mqtt

# --- MQTT 設定 ---
MQTT_BROKER = "mqttgo.io"
MQTT_PORT = 1883

TOPIC_TEMP   = "alex9ufo/temp"
TOPIC_HUMI   = "alex9ufo/humi"
TOPIC_LED    = "alex9ufo/led"
TOPIC_STATUS = "alex9ufo/status"

# --- 顏色定義 ---
COLOR_ON  = "#00FF00"  # 亮燈:綠色
COLOR_OFF = "#1E1E1E"  # 滅燈:黑色/深灰

# --- 模擬指示燈狀態控制 ---
def update_led_indicator(led_index, is_on):
    color = COLOR_ON if is_on else COLOR_OFF
    canvas_leds[led_index].itemconfig(led_circles[led_index], fill=color)

def handle_status_payload(payload):
    print(f"[除錯] 解析狀態 Payload: '{payload}'")  # 終端機除錯訊息
    
    # 單顆開關判斷
    if payload in ["1on", "2on", "3on", "4on"]:
        idx = int(payload[0]) - 1
        update_led_indicator(idx, True)
    elif payload in ["1off", "2off", "3off", "4off"]:
        idx = int(payload[0]) - 1
        update_led_indicator(idx, False)
    # 全開 / 全關判斷
    elif payload == "allon":
        for i in range(4):
            update_led_indicator(i, True)
    elif payload == "alloff":
        for i in range(4):
            update_led_indicator(i, False)

# --- MQTT 觀察者事件 ---
def on_connect(client, userdata, flags, rc, properties=None):
    print("已成功連線至 MQTT Broker,回傳碼:", rc)
    client.subscribe([(TOPIC_TEMP, 0), (TOPIC_HUMI, 0), (TOPIC_STATUS, 0)])

def on_message(client, userdata, msg):
    topic = msg.topic
    # 使用 strip() 確保清除 \r \n 或空白字元
    payload = msg.payload.decode('utf-8').strip()
    print(f"[MQTT 收到] 主題: {topic} | 內容: {payload}")
    
    if topic == TOPIC_TEMP:
        root.after(0, lambda: lbl_temp.config(text=f"{payload} °C"))
    elif topic == TOPIC_HUMI:
        root.after(0, lambda: lbl_humi.config(text=f"{payload} %"))
    elif topic == TOPIC_STATUS:
        # 使用 root.after 確保在 Tkinter 主執行緒更新介面
        root.after(0, lambda: lbl_status.config(text=f"最新狀態: {payload}"))
        root.after(0, lambda: handle_status_payload(payload))

def send_cmd(cmd):
    print(f"[MQTT 發送] 指令: {cmd}")
    client.publish(TOPIC_LED, cmd)

# --- GUI 介面建構 ---
root = tk.Tk()
root.title("ESP32 MQTT 控制與燈態中心")
root.geometry("400x520")
root.resizable(False, False)

# 1. 溫濕度顯示區
frame_sensor = ttk.LabelFrame(root, text=" 環境數據 ", padding=10)
frame_sensor.pack(fill="x", padx=15, pady=10)

ttk.Label(frame_sensor, text="溫度:", font=("Arial", 12)).grid(row=0, column=0, sticky="w", pady=5)
lbl_temp = ttk.Label(frame_sensor, text="-- °C", font=("Arial", 14, "bold"), foreground="red")
lbl_temp.grid(row=0, column=1, padx=10)

ttk.Label(frame_sensor, text="濕度:", font=("Arial", 12)).grid(row=1, column=0, sticky="w", pady=5)
lbl_humi = ttk.Label(frame_sensor, text="-- %", font=("Arial", 14, "bold"), foreground="blue")
lbl_humi.grid(row=1, column=1, padx=10)

# 2. LED 指示燈與控制區
frame_ctrl = ttk.LabelFrame(root, text=" LED 控制與實時狀態 ", padding=10)
frame_ctrl.pack(fill="x", padx=15, pady=5)

canvas_leds = []
led_circles = []

for i in range(1, 5):
    ttk.Label(frame_ctrl, text=f"LED {i}:", font=("Arial", 10, "bold")).grid(row=i-1, column=0, padx=5, pady=6)
    
    cv = tk.Canvas(frame_ctrl, width=24, height=24, highlightthickness=0)
    cv.grid(row=i-1, column=1, padx=5, pady=6)
    circle = cv.create_oval(2, 2, 22, 22, fill=COLOR_OFF, outline="#555555", width=1.5)
    
    canvas_leds.append(cv)
    led_circles.append(circle)
    
    btn_on = ttk.Button(frame_ctrl, text="開啟", width=6, command=lambda idx=i: send_cmd(f"{idx}on"))
    btn_on.grid(row=i-1, column=2, padx=5, pady=6)
    
    btn_off = ttk.Button(frame_ctrl, text="關閉", width=6, command=lambda idx=i: send_cmd(f"{idx}off"))
    btn_off.grid(row=i-1, column=3, padx=5, pady=6)

btn_all_on = ttk.Button(frame_ctrl, text="全開 (allon)", command=lambda: send_cmd("allon"))
btn_all_on.grid(row=4, column=0, columnspan=2, padx=5, pady=10, sticky="ew")

btn_all_off = ttk.Button(frame_ctrl, text="全關 (alloff)", command=lambda: send_cmd("alloff"))
btn_all_off.grid(row=4, column=2, columnspan=2, padx=5, pady=10, sticky="ew")

# 3. 狀態回傳顯示
lbl_status = ttk.Label(root, text="最新狀態: 尚未接收", font=("Arial", 10, "italic"))
lbl_status.pack(pady=10)

# --- 啟動 MQTT 背景監聽 ---
# 相容 paho-mqtt v1.x 與 v2.x 寫法
try:
    client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION1)
except AttributeError:
    client = mqtt.Client()

client.on_connect = on_connect
client.on_message = on_message

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

root.mainloop()




MQTTBO設定







2026年9月6日 星期日

Node-Red 抓取 opendata 的資料集產生AQI 空氣品質指

Node-Red 抓取 opendata 的資料集產生AQI 空氣品質指標




實現透過 Node-RED 抓取環境部 OpenData(空氣品質 AQI)資料,最核心的流程為:注入觸發(Inject) → HTTP 請求(http request) → 解析 JSON 資料(Function) → 輸出顯示(Debug 或 Dashboard 儀表板)

結構說明

  1. 統計測站總數 (ui_text)

    • API 抓取資料後,會自動計算全部的測站數量(例如 84 個),並在 Dashboard 最上方顯示:「統計資訊:全台共有 84 個觀測站」。

  2. 站名選單 (ui_dropdown)

    • 會將全台所有測站名稱(包含縣市標示,如:士林 (臺北市)古亭 (臺北市)基隆 (基隆市) ... 等)自動加入下拉式選單。

  3. 即時切換 AQI 儀表板 (ui_gauge)

    • 當您在下拉選單中選擇任一站名(如:選擇「萬華」或「彰化」),儀表板標題與指標會立即更新為該站點的 AQI 數據空氣品質狀態(如:良好、普通)

[{"id":"b7d023d3cb4f2077","type":"inject","z":"7dcbc129c166026e","name":"每15分鐘自動更新","props":[],"repeat":"900","crontab":"","once":true,"onceDelay":0.1,"topic":"","x":170,"y":160,"wires":[["6ceb34e3772b6260"]]},{"id":"6ceb34e3772b6260","type":"http request","z":"7dcbc129c166026e","name":"抓取環境部 AQI API","method":"GET","ret":"obj","paytoqs":"ignore","url":"https://data.moenv.gov.tw/api/v2/aqx_p_432?api_key=846e44e1-8cc5-4893-ad87-c79d2d383706&limit=1000&sort=ImportDate%20desc&format=JSON","tls":"","persist":false,"proxy":"","insecureHTTPParser":false,"authType":"","senderr":false,"headers":[],"x":400,"y":160,"wires":[["e40334474b756f29"]]},{"id":"e40334474b756f29","type":"function","z":"7dcbc129c166026e","name":"解析站名列表與總數","func":"var records = msg.payload.records || msg.payload || [];\nflow.set('aqiData', records);\n\nvar siteOptions = [];\nfor (var i = 0; i < records.length; i++) {\n    if (records[i].sitename) {\n        // ui_dropdown 支援直接以 \"顯示名稱\": \"傳遞數值\" 作為 Key-Value 物件\n        var item = {};\n        var labelName = records[i].sitename + \" (\" + (records[i].county || \"\") + \")\";\n        item[labelName] = records[i].sitename;\n        siteOptions.push(item);\n    }\n}\n\n// 1. 輸出測站總數\nvar msgCount = { payload: \"全台共有 \" + records.length + \" 個觀測站\" };\n\n// 2. 更新下拉選單(透過 msg.options 傳遞 Key-Value 陣列)\nvar msgDropdown = { options: siteOptions };\n\n// 3. 預設顯示第一個站名 (例如:基隆) 的 AQI\nvar defaultSite = records.length > 0 ? records[0].sitename : \"士林\";\nvar defaultAqi = records.length > 0 ? (parseInt(records[0].aqi) || 0) : 0;\nvar defaultStatus = records.length > 0 ? (records[0].status || \"\") : \"\";\nvar msgGauge = { \n    payload: defaultAqi, \n    topic: defaultSite + \" (空氣品質: \" + defaultStatus + \")\" \n};\n\nreturn [msgCount, msgDropdown, msgGauge];","outputs":3,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":650,"y":160,"wires":[["e1aed051ea6e4521","ea69f409aa5bec92"],["d2c7d42892ab5129"],["327d3a3f6cdce004"]]},{"id":"d2c7d42892ab5129","type":"ui_dropdown","z":"7dcbc129c166026e","name":"選擇測站","label":"選擇觀測站:","tooltip":"","place":"請選擇測站","group":"ce3f28d47263c160","order":2,"width":"6","height":"1","passthru":false,"multiple":false,"options":[],"payload":"","topic":"topic","topicType":"msg","className":"","x":400,"y":260,"wires":[["5b90367519b9ab1a"]]},{"id":"5b90367519b9ab1a","type":"function","z":"7dcbc129c166026e","name":"依選擇站名查詢 AQI","func":"var selectedSite = msg.payload;\nvar records = flow.get('aqiData') || [];\nvar aqiValue = 0;\nvar statusStr = \"未知\";\n\nfor (var i = 0; i < records.length; i++) {\n    if (records[i].sitename === selectedSite) {\n        aqiValue = parseInt(records[i].aqi) || 0;\n        statusStr = records[i].status || \"\";\n        break;\n    }\n}\n\nmsg.payload = aqiValue;\nmsg.topic = selectedSite + \" (空氣品質: \" + statusStr + \")\";\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":650,"y":260,"wires":[["327d3a3f6cdce004"]]},{"id":"e1aed051ea6e4521","type":"ui_text","z":"7dcbc129c166026e","group":"ce3f28d47263c160","order":1,"width":"6","height":"1","name":"測站總數","label":"統計資訊:","format":"{{msg.payload}}","layout":"row-spread","className":"","style":false,"font":"","fontSize":"","color":"#000000","x":880,"y":120,"wires":[]},{"id":"327d3a3f6cdce004","type":"ui_gauge","z":"7dcbc129c166026e","name":"AQI 指示表","group":"ce3f28d47263c160","order":3,"width":"6","height":"6","gtype":"gage","title":"{{msg.topic}} AQI 指數","label":"AQI","format":"{{value}}","min":0,"max":"300","colors":["#00e400","#ffff00","#ff0000"],"seg1":"50","seg2":"100","diff":false,"className":"","x":880,"y":260,"wires":[]},{"id":"ea69f409aa5bec92","type":"debug","z":"7dcbc129c166026e","name":"debug 7","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"false","statusVal":"","statusType":"auto","x":860,"y":60,"wires":[]},{"id":"ce3f28d47263c160","type":"ui_group","name":"Default","tab":"58d31a26312867b7","order":1,"disp":true,"width":6,"collapse":false,"className":""},{"id":"58d31a26312867b7","type":"ui_tab","name":"AQI Home","icon":"dashboard","disabled":false,"hidden":false},{"id":"a8740c13122a5163","type":"global-config","env":[],"modules":{"node-red-dashboard":"3.6.6"}}]

2026-09 MQTT基本觀念(作業1)

 2026-09  MQTT基本觀念(作業1) 將 執行結果 畫面 錄製約 3分鐘 以上 1) MQTT BOX 動作 畫面  2) Python + TKinter 動作 畫面  3) Wokwi ESP32 動作 畫面  並上傳YT (公開) mail alex9ufo@gm...