WiFi練習: 混合模式— WebServer + DHT22 + NTP + OpenWeatherMap

ESP32使用WiFi功能時,有3種模式:

  1. WIFI_STA,工作站模式(Station):當作普通設備,連接外部無線網路(如路由器)。
  2. WIFI_AP,熱點模式(Access Point):當作熱點供其他設備連接(如手機、電腦)。
  3. WIFI_AP_STA,混合模式(AP + Station):能連接外部無線網路,也能當作熱點供其他設備連接。

本次練習採用 WIFI_AP_STA 混合模式,希望達成下列功能:

  1. 以前次練習的「除濕機模擬系統」為基礎, ESP32 本機透過 DHT22 溫濕度感測器,可取得溫度、濕度等數據。
  2. WiFi 設定為混合模式。
  3. 透過工作站模式,連接外部無線網路,取得 NTP 網路時間,以及 OpenWeatherMap 網站的即時天氣、溫度、濕度、壓力等數據。
  4. 透過熱點模式,供其他設備連接,查看各項數據。
  5. 透過 Web Server 網頁介面,支援 AJAX 即時更新,能顯示:
    (1) 本機濕度、溫度、體感溫度、運轉狀態與模式。
    (2) NTP 網路時間。
    (3) OpenWeatherMap 網站上的即時天氣、溫度、濕度、壓力等訊息。

WIFI混合模式範例程式

// 保護標頭檔,避免同一個 .h 標頭檔被重複引用
#ifndef WIFI_SETUP_H
#define WIFI_SETUP_H
//-------------------------------------------------------------------------------------------------------------------------
#include <WiFi.h>                         // 引用WiFi函式庫
const char *sta_ssid = "SSID";            // 無線基地臺名稱(工作站模式)
const char *sta_password = "密碼";         // 無線基地臺密碼(工作站模式)
const char *ap_ssid = "熱點名稱";          // 熱點名稱(熱點模式)
const char *ap_password = "熱點密碼";      // 熱點密碼(熱點模式)
//-------------------------------------------------------------------------------------------------------------------------
inline void connect_to_wifi()    // 連線到WiFi(工作站模式)
{
  WiFi.begin(sta_ssid, sta_password);     // 啟動WiFi連線(工作站模式)
  Serial.println("\n----------------------------------");
  Serial.printf("Connecting to %s ", sta_ssid);
  while(WiFi.status() != WL_CONNECTED)    // 只要WiFi連線狀態不正常
  {
    delay(500);                           // 每0.5秒印出一個點
    Serial.print(".");
  }
  Serial.println(" CONNECTED!");
  Serial.print("SSID: ");
  Serial.println(WiFi.SSID());            // 印出SSID
  Serial.print("IP: ");
  Serial.println(WiFi.localIP());         // 印出IP
  Serial.print("Subnet Mask IP: ");
  Serial.println(WiFi.subnetMask());      // 印出子網路遮罩
  Serial.print("Gateway IP: ");
  Serial.println(WiFi.gatewayIP());       // 印出閘道IP
  Serial.print("DNS IP: ");
  Serial.println(WiFi.dnsIP());           // 印出DNS IP  
}
//-------------------------------------------------------------------------------------------------------------------------
inline void startAP()  // 啟用熱點
{
  while (!WiFi.softAP(ap_ssid, ap_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
//-----------------------------------------------------------------------
#include "wifi_setup.h"     // 引用 "wifi_setup.h"
//-----------------------------------------------------------------------

void setup() {
  //----------------------------------------------------------------------------------------
  Serial.begin(9600);       // 啟用串列埠監看視窗
  //----------------------------------------------------------------------------------------
  WiFi.mode(WIFI_AP_STA);   // 設定為混合模式
  //--------------------------------- 連接WiFi設定 ------------------------------------------
  connect_to_wifi();        // 連線到WiFi
  //--------------------------------- softAP設定 --------------------------------------------
  startAP();                // 啟用熱點
  WiFi.onEvent(WiFiEvent);  // 啟用WiFi事件處理
  listConnectedStations();  // 印出已連線裝置清單  
  //----------------------------------------------------------------------------------------
}

void loop() {
}

DHT 範例程式

// 保護標頭檔,避免同一個 .h 標頭檔被重複引用
#ifndef DHT_SETUP_H
#define DHT_SETUP_H
//-------------------------------------------------------------------------------------------------------------------------
#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;  // 華氏體感溫度
//-------------------------------------------------------------------------------------------------------------------------
inline void get_dht()   // 取得dht資料
{
	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");  
}
//-------------------------------------------------------------------------------------------------------------------------
#endif
//-----------------------------------------------------------------------
#include "dht_setup.h"      // 引用 "dht_setup.h"
//-----------------------------------------------------------------------
unsigned long previousMillis = 0;    // 前一次的millis()時間
const long interval = 2000;          // 預設計時的時間
//-----------------------------------------------------------------------

void setup() {
  //----------------------------------------------------------------------------------------
  Serial.begin(9600);       // 啟用串列埠監看視窗
  //--------------------------------- 連接 DHT22 --------------------------------------------
  dht.begin();              // 啟用dht物件
  //----------------------------------------------------------------------------------------
}

void loop() {
  unsigned long currentMillis = millis();          // 當前的millis()時間
  //---------------------------------------------------------------------
  if (currentMillis - previousMillis >= interval)  // 每經過一個interval的時間
  {
    get_dht();                        // 取得dht資料
    previousMillis = currentMillis;   // 更新前一次的millis()時間
  }
  //---------------------------------------------------------------------
}

NTP 範例程式

// 保護標頭檔,避免同一個 .h 標頭檔被重複引用
#ifndef NTP_SETUP_H
#define NTP_SETUP_H
//-------------------------------------------------------------------------------------------------------------------------
#include "time.h"
#include "sntp.h"
const char* ntpServer1 = "pool.ntp.org";        // NTP伺服器網址1
const char* ntpServer2 = "time.nist.gov";       // NTP伺服器網址2
const char* ntpServer3 = "time.stdtime.gov.tw"; // NTP伺服器網址3
const long  gmtOffset_sec = 28800;        // GMT+8,28800秒=8小時
const int   daylightOffset_sec = 0;       // 日光節約時間
//-------------------------------------------------------------------------------------------------------------------------
inline void configNTP()  // 設定NTP伺服器及參數
{
  // configTime(GMT偏移秒數, 日光節約偏移秒數, 伺服器網址1, 伺服器網址2, 伺服器網址3)
  configTime(gmtOffset_sec, daylightOffset_sec, ntpServer1, ntpServer2, ntpServer3);
} 
//-------------------------------------------------------------------------------------------------------------------------
inline void printLocalTime()	// 印出時間資料
{
  struct tm timeinfo;           // 建立一個時間結構,名稱為 timeinfo
  if(!getLocalTime(&timeinfo))  // 向NTP伺服器取得時間資料,若未取得時間資料,則返回
  {   
    Serial.println("No time available (yet)");   
    return;
  }
  Serial.println(&timeinfo, "%A, %B %d %Y %H:%M:%S");   // 印出時間資料(第1種資料格式)
  Serial.println(&timeinfo, "%F, %r");   // 印出時間資料(第2種資料格式)
}
//-------------------------------------------------------------------------------------------------------------------------
#endif
//-----------------------------------------------------------------------
#include "wifi_setup.h"     // 引用 "wifi_setup.h"
//-----------------------------------------------------------------------
#include "ntp_setup.h"      // 引用 "ntp_setup.h"
//-----------------------------------------------------------------------
unsigned long previousMillis = 0;    // 前一次的millis()時間
const long interval = 1000;          // 預設計時的時間
//-----------------------------------------------------------------------
void setup() {
  //----------------------------------------------------------------------------------------
  Serial.begin(9600);       // 啟用串列埠監看視窗
  //----------------------------------------------------------------------------------------
  WiFi.mode(WIFI_AP_STA);   // 設定為混合模式
  //--------------------------------- 連接WiFi設定 ------------------------------------------
  connect_to_wifi();        // 連線到WiFi
  //-------------------------------- NTP伺服器設定 ------------------------------------------
  configNTP();              // NTP伺服器設定
  //----------------------------------------------------------------------------------------
}

void loop() {
  unsigned long currentMillis = millis();          // 當前的millis()時間
  //---------------------------------------------------------------------
  if (currentMillis - previousMillis >= interval)  // 每經過一個interval的時間
  {
    printLocalTime();                 // 印出 NTP 時間資料
    previousMillis = currentMillis;   // 更新前一次的millis()時間
  }
  //---------------------------------------------------------------------  
}

OpenWeatherMap 範例程式

// 保護標頭檔,避免同一個 .h 標頭檔被重複引用
#ifndef WEATHER_SETUP_H
#define WEATHER_SETUP_H
//-------------------------------------------------------------------------------------------------------------------------
#include <HTTPClient.h>                 // 發送http請求,取得網站資料
HTTPClient http;
// String url = "https://api.openweathermap.org/data/2.5/weather?q=Taipei,TW&units=metric&appid=API金鑰";   // 網址
// 將網址中的城市、地區、API金鑰設定為變數,以方便後續程式操控
String city = "Taipei";
String countryCode = "TW";
String ApiKey = "API金鑰";
String url = "https://api.openweathermap.org/data/2.5/weather?q=" + city + "," + countryCode + "&units=metric&appid=" + ApiKey; // 網址
//-------------------------------------------------------------------------------------------------------------------------
#include <ArduinoJson.h>                // 解析JSON資料
String weatherDescription;              // 天氣概況
String temp;                            // 溫度
String pressure;                        // 大氣壓力
String humidity;                        // 溼度
//-------------------------------------------------------------------------------------------------------------------------
inline void get_weather_data()          // 取得天氣資料
{
  http.begin(url);                      // 開始連接網頁
  int httpCode = http.GET();            // 執行GET請求,回傳碼儲存於httpCode

  if (httpCode == HTTP_CODE_OK)         // 如果連線正常
  {
    String payload = http.getString();  // 傳回的網頁內容儲存於字串變數payload(承載量)
    // Serial.println(payload);         // 印出傳回的網頁內容
    
    // -------------------------------------------------------------
    // OpenWeatherMap JSON格式解析
    // -------------------------------------------------------------
    DynamicJsonDocument WeatherJson(payload.length() * 2);    // 宣告一個Json文件,名稱為WeatherJson(陣列格式)
    deserializeJson(WeatherJson, payload);                    // 解析payload為JSON Array格式

    weatherDescription = WeatherJson["weather"][0]["description"].as<String>();   // 取得天氣概況,weather是陣列,[0]是索引值
    temp = WeatherJson["main"]["temp"].as<String>();          // 取得溫度
    pressure = WeatherJson["main"]["pressure"].as<String>();  // 取得氣壓
    humidity = WeatherJson["main"]["humidity"].as<String>();  // 取得溼度
    Serial.println("----------------------------------");
    Serial.print("Weather description: ");
    Serial.println(weatherDescription);
    Serial.print("Temp: ");
    Serial.print(temp);
    Serial.println(" °C");
    Serial.print("Pressure: ");
    Serial.print(pressure);
    Serial.println(" hPa");
    Serial.print("Humidity: ");
    Serial.print(humidity);
    Serial.println(" %");
    Serial.println("----------------------------------");
  }
  else
  {
    Serial.print("HTTP GET failed, error code: ");      // 印出錯誤訊息
    Serial.println(httpCode);
  } 
  
  http.end();                           // 結束連線
}
//-------------------------------------------------------------------------------------------------------------------------
#endif
//-----------------------------------------------------------------------
#include "wifi_setup.h"     // 引用 "wifi_setup.h"
//-----------------------------------------------------------------------
#include "weather_setup.h"  // 引用 "weather_setup.h"
//-----------------------------------------------------------------------
unsigned long previousMillis = 0;    // 前一次的millis()時間
const long interval = 5000;          // 預設計時的時間
//-----------------------------------------------------------------------

void setup() {
  //----------------------------------------------------------------------------------------
  Serial.begin(9600);       // 啟用串列埠監看視窗
  //----------------------------------------------------------------------------------------
  WiFi.mode(WIFI_AP_STA);   // 設定為混合模式
  //--------------------------------- 連接WiFi設定 ------------------------------------------
  connect_to_wifi();        // 連線到WiFi  
  //----------------------------------------------------------------------------------------
}

void loop() {
  unsigned long currentMillis = millis();          // 當前的millis()時間
  //---------------------------------------------------------------------
  if (currentMillis - previousMillis >= interval)  // 若達到預設計時的時間
  {
    get_weather_data();               // 取得天氣資料
    previousMillis = currentMillis;   // 更新前一次的millis()時間
  }
  //---------------------------------------------------------------------
}

WebServer 範例程式

// 保護標頭檔,避免同一個 .h 檔被重複引用
#ifndef WEB_SETUP_H
#define WEB_SETUP_H
//-------------------------------------------------------------------------------------------------------------------------
#include <WebServer.h>    // 引用WebServer函式庫
#include "dht_setup.h"    // 引用dht_setup.h
//-------------------------------------------------------------------------------------------------------------------------
// 外部變數宣告,引用主程式中的變數
extern const int ledPin;  // LED 模擬除濕機
extern bool ledState;     // LED(除濕機)狀態
extern bool mode;         // 控制模式,false = 自動(auto), true = 手動(manual)
//-------------------------------------------------------------------------------------------------------------------------
WebServer server(80);     // 建立WebServer物件 (port 80)
//-------------------------------------------------------------------------------------------------------------------------
inline void handleRoot()  // 首頁
{
  String html = "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">"
                "<h2>ESP32 除濕機模擬系統</h2>"
                "<ul>"
                "<li><a href=\"/on\">手動啟動除濕機</a></li>"
                "<li><a href=\"/off\">手動停止除濕機</a></li>"
                "<li><a href=\"/auto\">切回自動模式</a></li>"
                "</ul>"
                "<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);"
                "updateStatus();"
                "</script>";

  server.send(200, "text/html; charset=UTF-8", html);
}
//-------------------------------------------------------------------------------------------------------------------------
inline void handleOn()      // 手動啟動除濕機
{
  mode = true;        // 切到手動模式
  ledState = true;    // 開啟除濕機
  server.sendHeader("Location", "/");
  server.send(303);
}
//-------------------------------------------------------------------------------------------------------------------------
inline void handleOff()     // 手動停止除濕機
{
  mode = true;        // 切到手動模式
  ledState = false;   // 關閉除濕機
  server.sendHeader("Location", "/");
  server.send(303);
}
//-------------------------------------------------------------------------------------------------------------------------
inline void handleAuto()    // 切換自動模式
{
  mode = false;       // 切回自動模式
  server.sendHeader("Location", "/");
  server.send(303);
}
//-------------------------------------------------------------------------------------------------------------------------
inline 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(ledState ? "運轉中" : "停止") + "\",";
  json += "\"mode\":\"" + String(mode ? "手動" : "自動") + "\"";
  json += "}";
  server.send(200, "application/json", json);
}
//-------------------------------------------------------------------------------------------------------------------------
inline void handleNotFound()  // 找不到網頁
{
  String html = "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">找不到網頁";
  server.send(404, "text/html; charset=UTF-8", html);
}
//-------------------------------------------------------------------------------------------------------------------------
inline void webServerSetup()  // WebServer設定
{
  server.on("/", handleRoot);         // 首頁
  server.on("/on", handleOn);         // 手動啟動除濕機
  server.on("/off", handleOff);       // 手動停止除濕機
  server.on("/status", handleStatus); // 更新狀態訊息
  server.on("/auto", handleAuto);     // 切換自動模式
  server.onNotFound(handleNotFound);  // 找不到網頁
  server.begin();                     // 啟動webServer
  Serial.println("Server已啟動");      // 印出提示文字
}
//-------------------------------------------------------------------------------------------------------------------------
inline void webServerHandle()   // 監聽客戶端請求
{
  server.handleClient();  // 檢查是否有客戶端向ESP32 WebServer發送請求
}
//-------------------------------------------------------------------------------------------------------------------------
#endif
//-----------------------------------------------------------------------
const int ledPin = 2;       // LED 模擬除濕機
bool ledState = false;      // LED(除濕機)狀態
bool mode = false;          // 控制模式,false = 自動(auto), true = 手動(manual)
//-----------------------------------------------------------------------
#include "wifi_setup.h"     // 引用 "wifi_setup.h"
//-----------------------------------------------------------------------
#include "dht_setup.h"      // 引用 "dht_setup.h"
//-----------------------------------------------------------------------
#include "web_setup.h"      // 引用 "web_setup.h"
//-----------------------------------------------------------------------
unsigned long previousMillis = 0;    // 前一次的millis()時間
const long interval = 2000;          // 預設計時的時間
//-----------------------------------------------------------------------

void setup() {
  //---------------------------------------------------
  pinMode(ledPin, OUTPUT);  // 設定LED為輸出腳,模擬除濕機
  //---------------------------------------------------
  Serial.begin(9600);       // 啟用串列埠監看視窗
  //---------------------------------------------------
  WiFi.mode(WIFI_AP_STA);   // 設定為混合模式
  //---------------------------------------------------
  connect_to_wifi();        // 連線到WiFi
  //------------------- softAP設定 --------------------
  startAP();                // 啟用熱點
  WiFi.onEvent(WiFiEvent);  // 啟用WiFi事件處理
  listConnectedStations();  // 印出已連線裝置清單   
  //---------------------------------------------------
  dht.begin();              // 啟用dht物件,連接 DHT22
  //---------------------------------------------------
  webServerSetup();         // WebServer設定
  //---------------------------------------------------
}

void loop() {
  unsigned long currentMillis = millis();          // 當前的millis()時間
  //---------------------------------------------------------------------
  if (currentMillis - previousMillis >= interval)  // 若達到預設計時的時間
  {
    //---------------------------------------------------
    get_dht();    // 每經過一個interval的時間,取得dht資料
    
    if (mode == 0)   // 自動除濕控制
    {
      if (h > 75) ledState = true;
      else if (h < 50) ledState = false;
    }
    
    digitalWrite(ledPin, ledState);   // LED 模擬除濕機運作
    //---------------------------------------------------
    
    previousMillis = currentMillis;   // 更新前一次的millis()時間
  }
  //---------------------------------------------------------------------

  webServerHandle();        // 監聽客戶端請求
}