WiFi練習:熱點模式—DHT22溫溼度感測器+遠端控制ESP32+AJAX即時更新

上週我們將不同動作的各個頁面進行整合,讓所有的訊息彙整呈現在首頁上,透過AJAX定期自動更新網頁內容。範例程式如下:

// 保護標頭檔,避免同一個 .h 標頭檔被重複引用
#ifndef WIFI_SETUP_H
#define WIFI_SETUP_H

//-------------------------------------------------------------------------------
#include <WiFi.h>                   // 引用WiFi函式庫
const char *ssid="SSID";            // 熱點名稱
const char *password="密碼";        // 熱點密碼
//-------------------------------------------------------------------------------
inline void startAP()  // 啟用熱點
{
  /*
  IPAddress local_ip(192,168,31,31);   // 設定熱點的IP位址
  IPAddress gateway(192,168,31,2);     // 設定熱點的閘道位址
  IPAddress subnet(255,255,255,0);     // 設定熱點的子網路遮罩位址
  WiFi.softAPConfig(local_ip, gateway, subnet); // 設定熱點參數(未設定時,熱點IP位址預設為192.168.4.1)
  */
  while (!WiFi.softAP(ssid, password))  // 若熱點尚未啟動,則每0.5秒印出一個點
  {
    delay(500);
    Serial.print(".");
  }
  Serial.println("\n----------------------------------");
  Serial.println("熱點啟動成功");
  Serial.print("IP Address: ");
  Serial.println(WiFi.softAPIP());  // 印出熱點的IP位址
}
//-------------------------------------------------------------------------------
#include <esp_wifi.h>                // 引用esp_wifi函式庫
inline void listConnectedStations()  // 印出已連線裝置清單
{
  wifi_sta_list_t station_list;             // 儲存連線裝置列表的結構
  esp_wifi_ap_get_sta_list(&station_list);  // 獲取當前連接到AP的所有裝置列表

  Serial.println("--------- 當前連線裝置清單 ---------");
  Serial.print("連線裝置數量: ");
  Serial.println(station_list.num);
  Serial.println("----------------------------------");

  if (station_list.num > 0) {
    Serial.println("編號       MAC 位址       IP 位址");
    Serial.println("----------------------------------");

    for (int i = 0; i < station_list.num; i++) {
      wifi_sta_info_t station = station_list.sta[i];

      // 取得 MAC 位址
      char macStr[18];
      snprintf(macStr, sizeof(macStr), "%02X:%02X:%02X:%02X:%02X:%02X", station.mac[0], station.mac[1], station.mac[2], station.mac[3], station.mac[4], station.mac[5]);

      // 取得 IP 位址 (需要透過 DHCP 租約列表)
      IPAddress ip = WiFi.softAPIP();
      ip[3] = i + 2;  // 預設分配從 .2 開始

      // 顯示裝置資訊
      Serial.printf("%2d  %s  %s\n", i + 1, macStr, ip.toString().c_str());
    }
  } else {
    Serial.println("目前沒有裝置連線");
  }

  Serial.println("----------------------------------");
}
//-------------------------------------------------------------------------------------------------------------------------
inline void WiFiEvent(WiFiEvent_t event, arduino_event_info_t info)  // WiFi事件處理函式
{
  uint8_t *mac = nullptr;   // 宣告MAC位址指標
  char macStr[18] = { 0 };  // 儲存格式化MAC位址的字串緩衝區

  switch (event) {
    case ARDUINO_EVENT_WIFI_AP_STACONNECTED:  // 裝置已連線
      // 從事件資訊中提取MAC位址,並將MAC位址格式化為字串(XX:XX:XX:XX:XX:XX)
      mac = info.wifi_ap_staconnected.mac;
      snprintf(macStr, sizeof(macStr), "%02X:%02X:%02X:%02X:%02X:%02X", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
      Serial.println("\n[事件] 裝置已連線");
      Serial.print("MAC: ");
      Serial.println(macStr);
      listConnectedStations();  // 更新並顯示當前連線裝置清單
      break;

    case ARDUINO_EVENT_WIFI_AP_STADISCONNECTED:  // 裝置斷開連線
      // 從事件資訊中提取MAC位址,並將MAC位址格式化為字串(XX:XX:XX:XX:XX:XX)
      mac = info.wifi_ap_stadisconnected.mac;
      snprintf(macStr, sizeof(macStr), "%02X:%02X:%02X:%02X:%02X:%02X", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
      Serial.println("\n[事件] 裝置已斷開連線");
      Serial.print("MAC: ");
      Serial.println(macStr);
      listConnectedStations();  // 更新並顯示當前連線裝置清單
      break;
  }
}
//-------------------------------------------------------------------------------
#endif
//-------------------------------------------------------------------------------------------------------------------------
const int ledPin = 2;              // LED接腳
bool ledState = LOW;               // LED狀態,初始值為LOW(OFF)
bool blinkEnable = LOW;            // 閃爍控制,初始值為LOW (停止)
unsigned long previousMillis = 0;  // 前一次的millis()時間
int interval = 500;                // 預設計時的時間(ms)
//-------------------------------------------------------------------------------------------------------------------------
#include "wifi_setup.h"  // 引用自行建義的 "wifi_setup.h"
//-------------------------------------------------------------------------------------------------------------------------
#include <WebServer.h>  // 引用WebServer函式庫
WebServer server(80);   // 建立WebServer物件, port為80

//-------------------------------------------------------------------------------------------------------------------------
void handleRoot()     // 根目錄
{
  String html = "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">"
                "<p>歡迎光臨!請點選下列連結:</p>"
                // 操作連結
                "<ul>"
                "<li><a href=\"/on\">打開 LED 燈</a></li>"
                "<li><a href=\"/off\">關閉 LED 燈</a></li>"
                "<li><a href=\"/blink\">啟用或停止閃爍 LED 燈</a></li>"
                "</ul>"

                // 設定閃爍時間
                "<p>設定LED閃爍時間(ms):</p>"
                "<form action=\"/setInterval\" method=\"get\" name=\"setInterval\">"
                "<input name=\"interval\" type=\"number\" value=\"" + String(interval) + "\" />"
                "<input type=\"submit\" value=\"設定\" />"
                "</form>"

                // AJAX 自動更新程式
                // 狀態顯示區塊(會被 AJAX 更新)
                "<div id=\"status\">AJAX 讀取中...</div>"
                "<script>"
                "function updateStatus(){"
                "  fetch('/status')"                              // 向 /status 發送請求
                "    .then(response => response.json())"          // 解析 JSON
                "    .then(data => {"
                "      let html = 'LED狀態:' + data.led + '<BR>';"
                "      html += '閃爍狀態:' + data.blink + '<BR>';"
                "      html += '閃爍間隔:' + data.interval + ' ms';"
                "      document.getElementById('status').innerHTML = html;"
                "    });"
                "}"
                "setInterval(updateStatus," + String(interval) + ");"   // 每個interval更新一次
                "updateStatus();"                    // 頁面載入時立即更新
                "</script>";

  server.send(200, "text/html; charset=UTF-8", html);
}

//-------------------------------------------------------------------------------------------------------------------------
void handleOn()   // LED ON
{
  blinkEnable = false;
  digitalWrite(ledPin, HIGH);
  ledState = HIGH;
  server.sendHeader("Location", "/");
  server.send(303);
}

//-------------------------------------------------------------------------------------------------------------------------
void handleOff()  // LED OFF
{
  blinkEnable = false;
  digitalWrite(ledPin, LOW);
  ledState = LOW;
  server.sendHeader("Location", "/");
  server.send(303);
}

//-------------------------------------------------------------------------------------------------------------------------
void handleBlink()  // LED Blink
{
  blinkEnable = !blinkEnable;
  server.sendHeader("Location", "/");
  server.send(303);
}

//-------------------------------------------------------------------------------------------------------------------------
void handleSetInterval()  // Setup Blink Interval
{
  if (server.hasArg("interval"))  // 檢查是否有收到參數
  {
    int newInterval = server.arg("interval").toInt();
    if (newInterval > 0)
    {
      interval = newInterval;
      blinkEnable = true;  // 設定後啟用閃爍
    }
  }
  server.sendHeader("Location", "/");
  server.send(303);
}

//-------------------------------------------------------------------------------------------------------------------------
void handleStatus()   // Status
{
  String json = "{";
  json += "\"led\":\"" + String(ledState ? "ON" : "OFF") + "\",";                 // ledState為1時,顯示“ON”,否則顯示“OFF”
  json += "\"blink\":\"" + String(blinkEnable ? "正在閃爍" : "停止閃爍") + "\",";   // blinkEnable為1時,顯示“正在閃爍”,否則顯示“停止閃爍”
  json += "\"interval\":" + String(interval);
  json += "}";
  server.send(200, "application/json", json);
}
//-------------------------------------------------------------------------------------------------------------------------
void handleNotFound()  // 找不到網頁
{
  String html = "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">找不到網頁";
  server.send(404, "text/html; charset=UTF-8", html);
}

//-------------------------------------------------------------------------------------------------------------------------
void setup() {
  //-------------------------------------------------------------------
  pinMode(ledPin, OUTPUT);  // 設定LED為輸出腳
  Serial.begin(9600);       // 啟用串列埠監看視窗
  //--------------------------- softAP設定 ----------------------------
  startAP();                // 啟用熱點
  WiFi.onEvent(WiFiEvent);  // 啟用WiFi事件處理
  listConnectedStations();  // 印出已連線裝置清單
  //---------------------------WebServer設定---------------------------
  server.on("/", handleRoot);                    // 在/時,前往handleRoot()
  server.on("/on", handleOn);                    // 在/on時,前往handleOn()
  server.on("/off", handleOff);                  // 在/off時,前往handleOff()
  server.on("/blink", handleBlink);              // 在/blink時,前往handleBlink()
  server.on("/setInterval", handleSetInterval);  // 在/setInterval時,前往handleSetInterval()
  server.on("/status", handleStatus);            // 在/status時,前往handleStatus()
  server.onNotFound(handleNotFound);             // 找不到網頁時,前往handleNotFound()  
  server.begin();                                // 啟動webServer
  Serial.println("Server已啟動");                 // 印出提示文字
  //-------------------------------------------------------------------
}

void loop() {
  server.handleClient();  // 檢查是否有客戶端向ESP32 WebServer發送請求

  //-------------------------------------------------------------------
  if (blinkEnable == true)
  {
    unsigned long currentMillis = millis();
    if (currentMillis - previousMillis >= interval)
    {
      previousMillis = currentMillis;
      ledState = !ledState;
      digitalWrite(ledPin, ledState);
    }
  }
  //-------------------------------------------------------------------
}

本週將以 ESP32 搭配 DHT22 溫濕度感測器作為實際案例練習,定時讀取溫度與濕度數據,並計算體感溫度。系統內建「自動」與「手動」兩種模式:自動模式下會依濕度高低自動控制除濕機啟動或停止(以 LED 模擬);手動模式則可透過網頁按鈕直接控制開關。網頁介面支援 AJAX 即時更新,能顯示濕度、溫度、體感溫度、運轉狀態與模式,以方便監控與操作。

作業說明:

  1. 利用DHT22偵測環境溫濕度,並將測得的數值顯示於Web Server上。
  2. 利用LED輸出模擬除濕機運轉。
  3. 濕度>75,除濕機運轉,直到濕度<50,除濕機停止運轉。
//---------------------------------------------------------------------
#include "DHT.h"            // 引用外部函式庫,名稱DHT
#define DHTPIN 23           // Digital pin connected to the DHT sensor
#define DHTTYPE DHT22       // DHT 22
DHT dht(DHTPIN, DHTTYPE);   // 建立DHT物件,名為dht
float h;    // 濕度資料
float t;    // 攝氏溫度
float f;    // 華氏溫度
float hic;  // 攝氏體感溫度
float hif;  // 華氏體感溫度
//---------------------------------------------------------------------
unsigned long previousMillis = 0;    // 前一次的millis()時間
const long interval = 2000;          // 預設計時的時間
//---------------------------------------------------------------------
void setup() {
  Serial.begin(9600);   // 啟用串列埠監看視窗
  dht.begin();          // 啟用dht物件
}

void loop() {
  unsigned long currentMillis = millis();          // 當前的millis()時間

  if (currentMillis - previousMillis >= interval)  // 若達到預設計時的時間
  {
    //---------------------------------------------------------------------
    // 每經過一個interval的時間,要做的事
    //---------------------------------------------------------------------
    h = dht.readHumidity();         // 取得濕度資料
    t = dht.readTemperature();      // 取得攝氏溫度
    f = dht.readTemperature(true);  // 取得華氏溫度

    // 若讀不到資料,則顯示錯誤訊息,並離開
    if (isnan(h) || isnan(t) || isnan(f))
    {
      Serial.println("Failed to read from DHT sensor!");
      return;
    }

    hic = dht.computeHeatIndex(t, h, false);  // 攝氏體感溫度
    hif = dht.computeHeatIndex(f, h, true);   // 華氏體感溫度
    
    // 將溫濕度數據顯示在監看視窗
    Serial.print("濕度: ");
    Serial.print(h);
    Serial.print("%,  溫度: ");
    Serial.print(t);
    Serial.print("°C ");
    Serial.print(f);
    Serial.print("°F,  體感溫度: ");
    Serial.print(hic);
    Serial.print("°C ");
    Serial.print(hif);
    Serial.println("°F");
    //---------------------------------------------------------------------

    previousMillis = currentMillis;  // 更新前一次的millis()時間
  }
}

設計思路

1、主頁面規劃:

2、WebServer設定

  server.on("/", handleRoot);         // 首頁
  server.on("/on", handleOn);         // 手動啟動除濕機
  server.on("/off", handleOff);       // 手動停止除濕機
  server.on("/status", handleStatus); // 更新狀態訊息
  server.on("/auto", handleAuto);     // 切換自動模式
  server.onNotFound(handleNotFound);  // 找不到網頁

3、目錄 /status ,負責傳遞下面的 JSON 格式參數。

{
  "humidity": h,            // 濕度
  "tempC": t,               // 攝氏溫度
  "tempF": f,               // 華氏溫度
  "heatIndexC": hic,        // 攝氏體感溫度
  "heatIndexF": hif,        // 華氏體感溫度
  "dehumidifier": "運轉中"  // 除濕機狀態"運轉中"或"停止"
  "mode": "自動"            // 模式"手動"或"自動"
}

4、handleStatus() 副程式責負傳遞需要更新的humidity、tempC、tempF、heatIndexC、heatIndexF、dehumidifier、mode等參數:

void handleStatus()
{
  String json = "{";
  json += "\"humidity\":" + String(h, 1) + ",";             // 濕度
  json += "\"tempC\":" + String(t, 1) + ",";                // 攝氏溫度
  json += "\"tempF\":" + String(f, 1) + ",";                // 華氏溫度
  json += "\"heatIndexC\":" + String(hic, 1) + ",";         // 攝氏體感溫度
  json += "\"heatIndexF\":" + String(hif, 1) + ",";         // 華氏體感溫度
  json += "\"dehumidifier\":\"" + String(dehumidifierState ? "運轉中" : "停止") + "\","; // 除濕機狀態
  json += "\"mode\":\"" + String(mode ? "手動" : "自動") + "\"";             // 模式
  json += "}";
  server.send(200, "application/json", json);
}

5、首頁 AJAX 狀態自動更新

                // AJAX 狀態自動更新
                "<div id=\"status\">AJAX 讀取中...</div>"
                "<script>"
                "function updateStatus(){"
                "  fetch('/status')"
                "    .then(response => response.json())"
                "    .then(data => {"
                "      let html = '';"
                "      html += '濕度:' + data.humidity + '%<br>';"
                "      html += '溫度:' + data.tempC + '°C / ' + data.tempF + '°F<br>';"
                "      html += '體感溫度:' + data.heatIndexC + '°C / ' + data.heatIndexF + '°F<br>';"
                "      html += '除濕機狀態:' + data.dehumidifier + '<br>';"
                "      html += '控制模式:' + data.mode;"
                "      document.getElementById('status').innerHTML = html;"
                "    });"
                "}"
                "setInterval(updateStatus,2000);" // 每2秒更新
                "updateStatus();"
                "</script>";

6、手動啟動除濕機

void handleOn()
{
  // 切到手動模式
  // 開啟除濕機
  server.sendHeader("Location", "/");
  server.send(303);
}

7、手動停止除濕機

void handleOff()
{
  // 切到手動模式
  // 關閉除濕機
  server.sendHeader("Location", "/");
  server.send(303);
}

8、切回自動模式

void handleAuto()
{
  // 切回自動模式
  server.sendHeader("Location", "/");
  server.send(303);
}

9、在主程式中判斷除濕機自動運轉的條件:濕度>75,除濕機運轉,直到濕度<50,除濕機停止運轉。