/*
小型溫室物聯網系統 - 8.6
*/
// =================== 核心與傳感器庫文件 ===================
#include <Wire.h> // I2C 通訊庫 (LCD)
#include <LiquidCrystal_I2C.h> // I2C LCD 驅動庫
#include <DHT.h> // DHT 溫濕度傳感器庫
// =================== IoT / 網路庫文件 ===================
#include <WiFi.h> // ESP32 WiFi 連線庫
#include "time.h" // NTP 網路時間庫
#include <AsyncTCP.h> // 異步 Web Server 依賴庫
#include <ESPAsyncWebServer.h> // 異步 Web Server 庫 (確保系統不被網頁請求阻塞)
// =================== 引腳定義 ===================
#define PIN_DHT 15 // DHT22 溫濕度數據線
#define PIN_TRIG 5 // 超聲波發射腳
#define PIN_ECHO 18 // 超聲波接收腳
#define PIN_RELAY_PUMP 4 // 澆灌水泵繼電器 (控制澆水)
#define PIN_RELAY_REFILL_PUMP 2 // 補水泵繼電器 (控制水箱補水)
#define PIN_SOIL 34 // 土壤傳感器/模擬電位器 (Analog 輸入)
#define PIN_STEP 19 // 步進馬達脈衝腳 (A4988 驅動)
#define PIN_DIR 23 // 步進馬達方向腳 (A4988 驅動)
const int ledPins[5] = {13, 12, 14, 27, 26}; // 5 顆 LED 水位指示燈
// =================== 系統配置與閥值 ===================
#define DHTTYPE DHT22
// WiFi/NTP 設定
const char* ssid = "Wokwi-GUEST"; // WiFi 名稱
const char* password = ""; // WiFi 密碼
const char* ntpServer = "pool.ntp.org"; // 網路時間服務器
const long gmtOffset_sec = 8 * 3600; // 設定時區 (GMT+8)
// 水箱設定 (單位: cm)
const float DIST_TANK_HEIGHT = 20.0;
const float DIST_EMPTY_THRESH = 15.0; // 缺水警報閾值 (距離 > 15cm,即水深 < 5cm)
// 補水功能設定 (新功能 1: 水箱自動補水)
const float DIST_LOW_WATER = 10.0; // 水位低於 10cm 開始補水
const float DIST_HIGH_WATER = 18.0; // 水位高於 18cm 停止補水
const unsigned long REFILL_TIMEOUT_MS = 60000; // 補水超時保護: 60秒
bool enableRefill = true; // 是否啟用自動補水功能
bool enableRefillTimeout = true; // 是否啟用超時保護功能 (模擬漏水測試)
// 自動澆水控制閥值 (單位: %)
const int SOIL_DRY_THRESH = 30; // 土壤濕度 < 30% 時,啟動澆水
const int SOIL_WET_THRESH = 70; // 土壤濕度 > 70% 時,停止澆水 (遲滯區間 30%~70% 防止頻繁開關)
// 自動通風控制閥值 (單位: 攝氏度 C)
const float TEMP_HOT_THRESH = 30.0; // 氣溫 > 30°C 時,打開通風/窗戶
const float TEMP_COOL_THRESH = 25.0; // 氣溫 < 25°C 時,關閉通風/窗戶
// =================== 全域物件與變數 ===================
DHT dht(PIN_DHT, DHTTYPE); // 建立 DHT 傳感器物件
LiquidCrystal_I2C lcd(0x27, 20, 4); // 建立 LCD 螢幕物件 (I2C 地址 0x27, 20字元 * 4行)
AsyncWebServer server(80); // Web Server 物件 (處理網頁請求)
// 歷史記錄結構體與緩衝區
struct LogEntry {
char timestamp[20];
char event[50];
};
#define MAX_LOGS 25 // 緩衝區大小:只保留最新的 N 筆記錄
LogEntry historyLogs[MAX_LOGS];
int currentLogIndex = 0; // 當前寫入的歷史記錄索引
// 狀態變數
float airTemp = 0;
float airHum = 0;
int soilMoisturePercent = 0;
float waterLevelCm = 0;
float distanceCm = 0;
bool isWatering = false;
bool isVentOpen = false;
bool tankEmpty = false;
bool isRefilling = false; // 追蹤補水泵狀態
unsigned long refillStartTime = 0; // 補水計時開始時間
unsigned long systemStartTime = 0; // 記錄系統開始運行時間
// 非阻塞計時器變數
unsigned long lastSensorTime = 0;
unsigned long lastScreenTime = 0;
const long SENSOR_INTERVAL = 2000;
const long SCREEN_INTERVAL = 1000;
// =================== 函式宣告 (Function Prototypes) ===================
void connectWiFiAndTime();
void initWebServer();
void readSensors();
void controlSystem();
void refillSystem();
void moveStepper(bool dir, int steps);
void updateDisplay();
void addLog(const char* event);
char* formatUptime(); // 將系統運行時間格式化為 "Xh XXm XXs"
// =================== 網路連線與時間函式 ===================
void connectWiFiAndTime() {
lcd.setCursor(0, 0); lcd.print("Connecting WiFi...");
WiFi.begin(ssid, password); // 嘗試連線
int count = 0;
while (WiFi.status() != WL_CONNECTED && count < 20) {
delay(500);
lcd.setCursor(0, 1); lcd.print(".");
count++;
}
if (WiFi.status() == WL_CONNECTED) {
// IF WiFi 連線成功 --> 同步 NTP 時間
configTime(gmtOffset_sec, 0, ntpServer);
struct tm timeinfo;
if (!getLocalTime(&timeinfo)) { // 檢查時間同步是否成功
lcd.clear(); lcd.print("NTP Sync Failed!");
}
} else {
lcd.clear(); lcd.print("WiFi Failed!");
}
lcd.clear();
}
// 格式化系統運行時間
char* formatUptime() {
unsigned long uptimeSeconds = (millis() - systemStartTime) / 1000;
static char buffer[15];
int days = uptimeSeconds / 86400;
int hours = (uptimeSeconds % 86400) / 3600;
int mins = (uptimeSeconds % 3600) / 60;
int secs = uptimeSeconds % 60;
// 根據運行時間長度選擇顯示格式
if (days > 0) {
snprintf(buffer, sizeof(buffer), "%dd %02dh %02dm", days, hours, mins);
} else {
snprintf(buffer, sizeof(buffer), "%02dh %02dm %02ds", hours, mins, secs);
}
return buffer;
}
// =================== 網頁伺服器初始化函式 ===================
void initWebServer() {
// 處理網頁根目錄 "/" 請求
server.on("/", HTTP_GET, [](AsyncWebServerRequest *request){
// 網頁內容生成
String html = "<html><head><title>Greenhouse Monitor</title><meta http-equiv='refresh' content='5'></head><body>";
// 網頁內容 - 即時數據
html += "<h1>溫室系統即時狀態</h1>";
html += "<p>IP: " + WiFi.localIP().toString() + "</p>";
html += "<p><strong>時間:</strong> " + String(formatUptime()) + "</p>";
html += "<p><strong>溫度/濕度:</strong> " + String(airTemp) + "C / " + String(airHum) + "%</p>";
html += "<p><strong>土壤濕度:</strong> " + String(soilMoisturePercent) + "%</p>";
html += "<p><strong>水位:</strong> " + String(waterLevelCm) + "cm (" + (tankEmpty ? "!!! 缺水 !!!" : "正常") + ")</p>";
html += "<p><strong>澆灌泵:</strong> " + (isWatering ? "ON" : "OFF") + " | <strong>補水泵:</strong> " + (isRefilling ? "ON" : "OFF") + "</p>";
html += "<p><strong>通風:</strong> " + (isVentOpen ? "OPEN" : "SHUT") + "</p>";
// 歷史記錄表格顯示
html += "<h2>系統歷史記錄 (最新 25 筆)</h2>";
html += "<table border='1'><tr><th>時間戳</th><th>事件</th></tr>";
// 循環遍歷歷史記錄緩衝區 (從最新一筆往前顯示)
for (int i = 0; i < MAX_LOGS; i++) {
// 計算循環索引 (確保從最新的紀錄開始倒序讀取)
int index = (currentLogIndex - 1 - i + MAX_LOGS) % MAX_LOGS;
// 只有當該條記錄的時間戳非空時才顯示
if (strlen(historyLogs[index].timestamp) > 0) {
html += "<tr><td>" + String(historyLogs[index].timestamp) + "</td><td>" + String(historyLogs[index].event) + "</td></tr>";
}
}
html += "</table></body></html>";
request->send(200, "text/html", html); // 發送 HTTP 200 響應
});
server.begin(); // 啟動 Web Server
addLog("Web Server Started"); // 記錄啟動事件
}
// =================== 系統歷史記錄函式 (新功能 3) ===================
void addLog(const char* event) {
struct tm timeinfo;
getLocalTime(&timeinfo); // 獲取當前網路時間
char timeStr[20];
// 格式化時間 (取時分秒; 網頁會加上日期)
strftime(timeStr, sizeof(timeStr), "%H:%M:%S", &timeinfo);
// 寫入歷史記錄陣列 (儲存到循環緩衝區)
snprintf(historyLogs[currentLogIndex].timestamp, 20, "%s", timeStr);
snprintf(historyLogs[currentLogIndex].event, 50, "%s", event);
Serial.printf("LOGGED: %s - %s\n", timeStr, event);
// 更新索引 + 準備寫入下一個位置
currentLogIndex = (currentLogIndex + 1) % MAX_LOGS;
}
// =================== 初始化設定 Setup ===================
void setup() {
Serial.begin(115200);
// 設定引腳
pinMode(PIN_TRIG, OUTPUT);
pinMode(PIN_ECHO, INPUT);
pinMode(PIN_RELAY_PUMP, OUTPUT);
pinMode(PIN_RELAY_REFILL_PUMP, OUTPUT);
pinMode(PIN_STEP, OUTPUT);
pinMode(PIN_DIR, OUTPUT);
// 確保所有繼電器初始關閉 (for safety)
digitalWrite(PIN_RELAY_PUMP, LOW);
digitalWrite(PIN_RELAY_REFILL_PUMP, LOW);
// 初始化 LED Bar 引腳
for(int i=0; i<5; i++) {
pinMode(ledPins[i], OUTPUT);
digitalWrite(ledPins[i], LOW);
}
dht.begin();
lcd.init();
lcd.backlight();
// 1. 連接 WiFi 並同步 NTP 時間
connectWiFiAndTime();
// 2. 啟動 Web Server
initWebServer();
// 記錄系統開始時間,用於計算運行時間
systemStartTime = millis();
addLog("System Booted Up");
}
// =================== 主循環 (Main Loop) ===================
void loop() {
unsigned long currentMillis = millis();
// 1. 任務: 讀取傳感器與執行控制邏輯
if (currentMillis - lastSensorTime >= SENSOR_INTERVAL) {
lastSensorTime = currentMillis;
readSensors();
controlSystem(); // 處理澆灌和通風
refillSystem(); // 處理水箱補水邏輯
}
// 2. 任務: 更新 LCD 顯示
if (currentMillis - lastScreenTime >= SCREEN_INTERVAL) {
lastScreenTime = currentMillis;
updateDisplay();
}
}
// =================== 傳感器數據讀取函式 ===================
void readSensors() {
// 讀取 DHT22
airTemp = dht.readTemperature();
airHum = dht.readHumidity();
// 讀取土壤濕度
int rawSoil = analogRead(PIN_SOIL);
soilMoisturePercent = map(rawSoil, 0, 4095, 0, 100);
// 讀取超聲波距離 (水箱水位)
digitalWrite(PIN_TRIG, LOW); delayMicroseconds(2);
digitalWrite(PIN_TRIG, HIGH); delayMicroseconds(10);
digitalWrite(PIN_TRIG, LOW);
long duration = pulseIn(PIN_ECHO, HIGH);
distanceCm = duration * 0.034 / 2;
waterLevelCm = DIST_TANK_HEIGHT - distanceCm;
if (waterLevelCm < 0) waterLevelCm = 0;
// 檢查水箱缺水狀態並記錄變化
bool wasTankEmpty = tankEmpty;
if (distanceCm >= DIST_EMPTY_THRESH) tankEmpty = true;
else tankEmpty = false;
// 如果水位剛從正常變成臨界低,發出 Log 警報
if (tankEmpty && !wasTankEmpty) addLog("[!!!] Water Level CRITICAL LOW!");
// 更新 LED Bar (水位視覺化)
int ledLevel = map((int)waterLevelCm, 0, (int)DIST_TANK_HEIGHT, 0, 5);
for(int i=0; i<5; i++) {
if (i < ledLevel) digitalWrite(ledPins[i], HIGH);
else digitalWrite(ledPins[i], LOW);
}
}
// =================== 控制 (澆灌 + 通風) ===================
void controlSystem() {
// ### 澆水控制邏輯
bool wasWatering = isWatering;
if (tankEmpty) { // 安全保護機制: 缺水時強制關閉澆灌泵
digitalWrite(PIN_RELAY_PUMP, LOW);
isWatering = false;
}
else {
// 澆水判斷
if (!isWatering && soilMoisturePercent < SOIL_DRY_THRESH) {
digitalWrite(PIN_RELAY_PUMP, HIGH);
isWatering = true;
}
else if (isWatering && soilMoisturePercent > SOIL_WET_THRESH) {
digitalWrite(PIN_RELAY_PUMP, LOW);
isWatering = false;
}
}
// 記錄澆灌狀態變化
if (isWatering != wasWatering) addLog(isWatering ? "Pump ON (Soil Dry)" : "Pump OFF (Soil Wet)");
// ### 通風控制邏輯 (步進馬達)
bool wasVentOpen = isVentOpen;
if (!isVentOpen && airTemp > TEMP_HOT_THRESH) {
moveStepper(true, 200); // 正轉開窗
isVentOpen = true;
}
else if (isVentOpen && airTemp < TEMP_COOL_THRESH) {
moveStepper(false, 200); // 反轉關窗
isVentOpen = false;
}
// 記錄通風狀態變化
if (isVentOpen != wasVentOpen) addLog(isVentOpen ? "Vent OPEN (Temp High)" : "Vent SHUT (Temp Low)");
}
// =================== 補水系統邏輯 ===================
void refillSystem() {
if (!enableRefill) return; // 如果補水功能被關閉,直接跳過
if (!isRefilling) {
// 狀態 1: 檢查是否需要開始補水
if (waterLevelCm < DIST_LOW_WATER) {
digitalWrite(PIN_RELAY_REFILL_PUMP, HIGH); // 補水泵 opening~~
isRefilling = true;
refillStartTime = millis(); // 記錄開始時間
addLog("Refill STARTED (Water Low)");
}
} else {
// 狀態 2: 正在補水中,檢查是否需要停止
// 條件 A: 補滿水了 (高於停止閥值) -> 停止
if (waterLevelCm > DIST_HIGH_WATER) {
digitalWrite(PIN_RELAY_REFILL_PUMP, LOW);
isRefilling = false;
addLog("Refill STOPPED (Water Full)");
return;
}
// 條件 B: 超時保護 -> 強制停止 (避免漏水導致系統無限運行)
if (enableRefillTimeout && (millis() - refillStartTime >= REFILL_TIMEOUT_MS)) {
digitalWrite(PIN_RELAY_REFILL_PUMP, LOW); // 強制停止補水
isRefilling = false;
addLog("[!!!] Refill TIMEOUT! Forced STOP");
// 重要警報
}
}
}
// =================== 步進馬達驅動函式 (Helper) ===================
void moveStepper(bool dir, int steps) {
digitalWrite(PIN_DIR, dir ? HIGH : LOW);
for(int i=0; i<steps; i++) {
digitalWrite(PIN_STEP, HIGH);
delayMicroseconds(1000); // 脈衝寬度控制轉速
digitalWrite(PIN_STEP, LOW);
delayMicroseconds(1000);
}
}
// =================== 螢幕顯示更新函式 ===================
void updateDisplay() {
struct tm timeinfo;
getLocalTime(&timeinfo); // 獲取當前 NTP 時間
char timeStr[10];
strftime(timeStr, sizeof(timeStr), "%H:%M:%S", &timeinfo); // 格式化為 HH:MM:SS
static int page = 0;
page = !page; // 切換頁面 (目前單頁; 可擴充)
lcd.clear();
// 行 0: [HH:MM:SS] [ Up: Xh XXm ] (實時時間與運行時間)
lcd.setCursor(0, 0);
lcd.print(timeStr);
lcd.setCursor(11, 0); // 從第 11 個字元開始顯示運行時間
lcd.printf("Up: %s", formatUptime());
// 行 1: [ T:XX.XC H:XX.X% Soil:XX% ] (環境感測數據)
lcd.setCursor(0, 1);
lcd.printf("T:%.1f%cC H:%.1f%% Soil:%d%%", airTemp, 223, airHum, soilMoisturePercent); // 223 為 ° 符號
// 行 2: [ PMP:ON/OFF Vent:OPEN/SHUT ] (澆灌與通風狀態)
lcd.setCursor(0, 2);
lcd.printf("PMP:%s Vent:%s", isWatering ? "ON" : "OFF", isVentOpen ? "OPEN" : "SHUT");
// 行 3: [ Tank:XX.Xcm Refill:RUN/STOP ] (水箱與補水狀態)
lcd.setCursor(0, 3);
lcd.printf("Tank:%.1fcm Refill:%s", waterLevelCm, isRefilling ? "RUN" : "STOP");
}