#include <Arduino.h>
#include "DHT.h"
#include <WiFi.h>
#include <PubSubClient.h>
#include <esp_task_wdt.h>
#include <ArduinoJson.h>
#include <Wire.h>
#include <LiquidCrystal_I2C.h>
// ══════════════════════════════════════════════════════════════
// RELAY CONFIGURATION — READ BEFORE FLASHING
// Most single channel relay modules → Active HIGH (default)
// Multi channel relay boards (2/4/8ch) → May need Active LOW
// If device turns ON at boot unexpectedly → swap the values
// ══════════════════════════════════════════════════════════════
#define RELAY_ON LOW
#define RELAY_OFF HIGH
// ── WiFi Credentials ───────────────────────────────────────────
#define WIFI_SSID "Wokwi-GUEST"
#define WIFI_PASSWORD ""
// ── MQTT Broker ────────────────────────────────────────────────
#define MQTT_BROKER "test.mosquitto.org"
#define MQTT_PORT 1883
// ── MQTT Topics — Humidifier ───────────────────────────────────
#define TOPIC_TELEMETRY "greenhouse/room1/telemetry"
#define TOPIC_FAULT "greenhouse/room1/fault"
#define TOPIC_SYSTEM "greenhouse/room1/system"
#define TOPIC_STATUS "greenhouse/room1/status"
#define TOPIC_COMMAND "greenhouse/room1/command"
#define TOPIC_MODE "greenhouse/room1/mode"
#define TOPIC_TEMP "greenhouse/room1/telemetry/temp"
#define TOPIC_HUMIDITY "greenhouse/room1/telemetry/humidity"
#define TOPIC_HUMIDIFIER_STATE "greenhouse/room1/telemetry/humidifier"
// ── MQTT Topics — Fan ──────────────────────────────────────────
#define TOPIC_FAN_STATE "greenhouse/room1/fan/state"
#define TOPIC_FAN_MODE "greenhouse/room1/fan/mode"
// ── Sensor Pin ─────────────────────────────────────────────────
#define DHTPIN 4
#define DHTTYPE DHT22
// ── Relay Pins ─────────────────────────────────────────────────
#define HUMIDIFIER_PIN 18
#define FAN_PIN 13
// NOTE: Relay VCC must be powered from 5V/VIN not 3.3V
// Two relay coils drawing current from 3.3V causes brownouts
// Signal (IN) from GPIO only — coil power from 5V
// ── Button Pins ────────────────────────────────────────────────
// GPIO32/33/25/26/14/17 — internal pullup sufficient
// GPIO39 — input only, NO internal pullup
// REQUIRES external 10kΩ pullup to 3.3V
// Wiring: 3.3V → 10kΩ → GPIO39 → button → GND
#define BTN_HUM_AUTO 32
#define BTN_HUM_FORCE_ON 33
#define BTN_HUM_FORCE_OFF 25
#define BTN_FAN_AUTO 14
#define BTN_FAN_FORCE_ON 17
#define BTN_FAN_FORCE_OFF 39
#define BTN_SCREEN 26
// ── LED Pins ───────────────────────────────────────────────────
// Wiring: GPIO → 220Ω → LED anode → LED cathode → GND
#define LED_HUM_AUTO 19 // Green — humidifier in AUTO
#define LED_FAN_AUTO 16 // Green — fan in AUTO
#define LED_FAN_ON 23 // White — fan relay currently ON
#define LED_FAULT 27 // Red — fast blink on sensor fault
// ── LCD ────────────────────────────────────────────────────────
// Default I2C pins — Wire.begin() with no arguments
// SDA → GPIO21 SCL → GPIO22
#define LCD_ADDR 0x27
#define LCD_COLS 16
#define LCD_ROWS 2
// ── Timing ─────────────────────────────────────────────────────
#define BAUD_RATE 115200
#define READ_INTERVAL 2000
#define SENSOR_TIMEOUT 30000
#define SYSTEM_INTERVAL 60000
#define HEARTBEAT_MAX 30000
#define WIFI_TIMEOUT 15000
#define MQTT_TIMEOUT 15000
#define DEBOUNCE_MS 40
#define FAULT_BLINK_MS 100 // 5Hz fast blink
#define LCD_REFRESH_MS 500
#define MANUAL_PAUSE_MS 15000
// ── Humidifier Thresholds ──────────────────────────────────────
#define HUMIDITY_ON_THRESHOLD_DEFAULT 70.0
#define HUMIDITY_OFF_THRESHOLD_DEFAULT 90.0
#define HUMIDITY_MIN_VALID 40.0
#define HUMIDITY_MAX_VALID 99.0
#define TRIGGER_DELAY 10000
// ── Fan Timer Defaults ─────────────────────────────────────────
// Calculated for 10m³ greenhouse with 6 oyster bags
// 1 min ON / 28 min OFF ≈ 2 air exchanges per hour
// Configurable via MQTT without reflashing
#define FAN_ON_DURATION_DEFAULT 60 // seconds
#define FAN_OFF_DURATION_DEFAULT 1680 // seconds (28 minutes)
// ── Publish ────────────────────────────────────────────────────
#define PUBLISH_DELTA 0.5
#define BACKOFF_MIN 2000
#define BACKOFF_MAX 60000
#define MQTT_MAX_PAYLOAD 128
// ══════════════════════════════════════════════════════════════
// CONTROL MODES
// ══════════════════════════════════════════════════════════════
enum ControlMode {
AUTO,
FORCED_ON,
FORCED_OFF
};
DHT dht(DHTPIN, DHTTYPE);
WiFiClient espClient;
PubSubClient mqtt(espClient);
LiquidCrystal_I2C lcd(LCD_ADDR, LCD_COLS, LCD_ROWS);
// ── Runtime State ──────────────────────────────────────────────
ControlMode humMode = AUTO;
ControlMode fanMode = AUTO;
bool humidifierOn = false;
bool fanOn = false;
bool sensorFaultActive = false;
bool lowHumTimerActive = false;
// ── Thresholds ─────────────────────────────────────────────────
float humidityOnThreshold = HUMIDITY_ON_THRESHOLD_DEFAULT;
float humidityOffThreshold = HUMIDITY_OFF_THRESHOLD_DEFAULT;
// ── Fan Timer ──────────────────────────────────────────────────
unsigned long fanOnDuration = FAN_ON_DURATION_DEFAULT * 1000UL;
unsigned long fanOffDuration = FAN_OFF_DURATION_DEFAULT * 1000UL;
unsigned long fanCycleStart = 0;
bool fanCyclePhase = false; // false=OFF phase, true=ON phase
// ── Sensor Values (shared with LCD) ───────────────────────────
float currentTemp = 0;
float currentHumidity = 0;
// ── Timestamps ─────────────────────────────────────────────────
unsigned long lastReadTime = 0;
unsigned long lastValidReadTime = 0;
unsigned long lastSystemPublish = 0;
unsigned long lastTelemetryPublish = 0;
unsigned long lowHumStartTime = 0;
unsigned long lastReconnectAttempt = 0;
unsigned long backoffDelay = BACKOFF_MIN;
unsigned long bootTime = 0;
// ── Button State ───────────────────────────────────────────────
struct ButtonState {
bool lastState;
bool armed;
unsigned long lastChange;
};
ButtonState btnHumAuto = {HIGH, true, 0};
ButtonState btnHumForceOn = {HIGH, true, 0};
ButtonState btnHumForceOff = {HIGH, true, 0};
ButtonState btnFanAuto = {HIGH, true, 0};
ButtonState btnFanForceOn = {HIGH, true, 0};
ButtonState btnFanForceOff = {HIGH, true, 0};
ButtonState btnScreen = {HIGH, true, 0};
// ── LED Blink State ────────────────────────────────────────────
unsigned long lastFaultBlinkTime = 0;
bool faultBlinkState = false;
// ── LCD State ──────────────────────────────────────────────────
uint8_t currentScreen = 0;
unsigned long lastButtonPress = 0;
unsigned long lastLCDRefresh = 0;
// ── Last Published Values ──────────────────────────────────────
float lastPublishedTemp = -999;
float lastPublishedHumidity = -999;
bool lastPublishedHumState = false;
bool lastPublishedFanState = false;
// ── Forward Declarations ───────────────────────────────────────
void setHumidifier(bool state, const char* reason);
void setFan(bool state, const char* reason);
void publishTelemetry(float tempC, float humidity);
void publishSystem();
void publishFault(const char* message);
void publishModeChange(const char* device, const char* source);
void handleCommand(const char* payload);
void updateLEDs();
void handleButtons();
void handleSingleButton(ButtonState &btn, uint8_t pin, void (*onPress)());
void updateLCD();
void lcdPrintPadded(uint8_t col, uint8_t row, const char* text);
void controlHumidifier(float humidity);
void controlFan();
void checkSensorFailsafe();
void handleSensorRecovery();
void maintainConnections();
// ── Generate unique MQTT client ID ────────────────────────────
void getMQTTClientId(char* buffer, size_t size) {
uint64_t mac = ESP.getEfuseMac();
snprintf(buffer, size, "ESP32_GH_%04X%08X",
(uint16_t)(mac >> 32),
(uint32_t)mac
);
}
// ── LCD padded print — no lcd.clear(), overwrites in place ────
void lcdPrintPadded(uint8_t col, uint8_t row, const char* text) {
char buffer[17];
snprintf(buffer, sizeof(buffer), "%-16s", text);
lcd.setCursor(col, row);
lcd.print(buffer);
}
// ── Set humidifier relay ───────────────────────────────────────
void setHumidifier(bool state, const char* reason) {
if (humidifierOn == state) return;
humidifierOn = state;
digitalWrite(HUMIDIFIER_PIN, state ? RELAY_ON : RELAY_OFF);
if (mqtt.connected()) {
mqtt.publish(TOPIC_HUMIDIFIER_STATE, humidifierOn ? "true" : "false", true);
}
Serial.printf(">> Humidifier %s — %s\n", state ? "ON" : "OFF", reason);
}
// ── Set fan relay ──────────────────────────────────────────────
void setFan(bool state, const char* reason) {
if (fanOn == state) return;
fanOn = state;
digitalWrite(FAN_PIN, state ? RELAY_ON : RELAY_OFF);
if (mqtt.connected()) {
mqtt.publish(TOPIC_FAN_STATE, fanOn ? "true" : "false", true);
}
Serial.printf(">> Fan %s — %s\n", state ? "ON" : "OFF", reason);
}
// ── Publish mode change with source and device tags ───────────
void publishModeChange(const char* device, const char* source) {
if (!mqtt.connected()) return;
ControlMode m = (strcmp(device, "humidifier") == 0) ? humMode : fanMode;
char payload[128];
snprintf(payload, sizeof(payload),
"{\"device\":\"%s\",\"mode\":\"%s\",\"source\":\"%s\"}",
device,
m == AUTO ? "AUTO" : m == FORCED_ON ? "FORCED_ON" : "FORCED_OFF",
source
);
mqtt.publish(TOPIC_MODE, payload);
Serial.printf(">> Mode published: %s\n", payload);
}
// ── Publish telemetry ─────────────────────────────────────────
void publishTelemetry(float tempC, float humidity) {
char jsonPayload[256];
snprintf(jsonPayload, sizeof(jsonPayload),
"{\"temperature\":%.2f,\"humidity\":%.2f,\"humidifier\":%s,\"fan\":%s,\"hum_mode\":\"%s\",\"fan_mode\":\"%s\"}",
tempC, humidity,
humidifierOn ? "true" : "false",
fanOn ? "true" : "false",
humMode == AUTO ? "AUTO" : humMode == FORCED_ON ? "FORCED_ON" : "FORCED_OFF",
fanMode == AUTO ? "AUTO" : fanMode == FORCED_ON ? "FORCED_ON" : "FORCED_OFF"
);
mqtt.publish(TOPIC_TELEMETRY, jsonPayload);
char buf[16];
snprintf(buf, sizeof(buf), "%.2f", tempC);
mqtt.publish(TOPIC_TEMP, buf);
snprintf(buf, sizeof(buf), "%.2f", humidity);
mqtt.publish(TOPIC_HUMIDITY, buf);
lastPublishedTemp = tempC;
lastPublishedHumidity = humidity;
lastPublishedHumState = humidifierOn;
lastPublishedFanState = fanOn;
lastTelemetryPublish = millis();
Serial.printf(">> Telemetry published: %s\n", jsonPayload);
}
// ── Publish system diagnostics ────────────────────────────────
void publishSystem() {
char payload[128];
snprintf(payload, sizeof(payload),
"{\"rssi\":%d,\"heap\":%lu,\"uptime\":%lu}",
WiFi.RSSI(),
(unsigned long)ESP.getFreeHeap(),
(millis() - bootTime) / 1000
);
mqtt.publish(TOPIC_SYSTEM, payload);
Serial.printf(">> System published: %s\n", payload);
lastSystemPublish = millis();
}
// ── Publish fault ─────────────────────────────────────────────
void publishFault(const char* message) {
if (!mqtt.connected()) return;
char payload[128];
snprintf(payload, sizeof(payload),
"{\"fault\":\"%s\",\"uptime\":%lu}",
message,
(millis() - bootTime) / 1000
);
mqtt.publish(TOPIC_FAULT, payload);
Serial.printf(">> FAULT published: %s\n", payload);
}
// ── MQTT command handler ───────────────────────────────────────
void handleCommand(const char* payload) {
StaticJsonDocument<128> doc;
DeserializationError err = deserializeJson(doc, payload);
if (err) {
Serial.printf(">> Command parse error: %s\n", err.c_str());
return;
}
// ── Humidifier override ──────────────────────────────────
if (doc.containsKey("override")) {
const char* val = doc["override"];
if (strcmp(val, "ON") == 0) {
humMode = FORCED_ON;
setHumidifier(true, "MQTT override ON");
publishModeChange("humidifier", "mqtt");
} else if (strcmp(val, "OFF") == 0) {
humMode = FORCED_OFF;
setHumidifier(false, "MQTT override OFF");
publishModeChange("humidifier", "mqtt");
} else if (strcmp(val, "AUTO") == 0) {
humMode = AUTO;
lowHumTimerActive = false;
Serial.println(">> Humidifier AUTO via MQTT");
publishModeChange("humidifier", "mqtt");
}
}
// ── Fan override ─────────────────────────────────────────
if (doc.containsKey("fan_override")) {
const char* val = doc["fan_override"];
if (strcmp(val, "ON") == 0) {
fanMode = FORCED_ON;
setFan(true, "MQTT fan override ON");
publishModeChange("fan", "mqtt");
} else if (strcmp(val, "OFF") == 0) {
fanMode = FORCED_OFF;
setFan(false, "MQTT fan override OFF");
publishModeChange("fan", "mqtt");
} else if (strcmp(val, "AUTO") == 0) {
fanMode = AUTO;
fanCycleStart = millis();
fanCyclePhase = false;
setFan(false, "fan returning to AUTO — cycle reset");
Serial.println(">> Fan AUTO via MQTT — cycle restarted");
publishModeChange("fan", "mqtt");
}
}
// ── Fan timer configuration ───────────────────────────────
if (doc.containsKey("fan_on_duration")) {
unsigned long val = (unsigned long)doc["fan_on_duration"] * 1000UL;
if (val >= 10000UL && val <= 600000UL) {
fanOnDuration = val;
Serial.printf(">> Fan ON duration updated: %lus\n", val / 1000);
} else {
Serial.println(">> Fan ON duration rejected — out of range (10-600s)");
}
}
if (doc.containsKey("fan_off_duration")) {
unsigned long val = (unsigned long)doc["fan_off_duration"] * 1000UL;
if (val >= 60000UL && val <= 7200000UL) {
fanOffDuration = val;
Serial.printf(">> Fan OFF duration updated: %lus\n", val / 1000);
} else {
Serial.println(">> Fan OFF duration rejected — out of range (60-7200s)");
}
}
// ── Humidity threshold update ─────────────────────────────
float newOn = doc.containsKey("threshold_on") ? (float)doc["threshold_on"] : humidityOnThreshold;
float newOff = doc.containsKey("threshold_off") ? (float)doc["threshold_off"] : humidityOffThreshold;
if (doc.containsKey("threshold_on") || doc.containsKey("threshold_off")) {
if (newOn < HUMIDITY_MIN_VALID || newOn > HUMIDITY_MAX_VALID ||
newOff < HUMIDITY_MIN_VALID || newOff > HUMIDITY_MAX_VALID ||
newOn >= newOff) {
Serial.printf(">> Threshold rejected — invalid (on:%.1f off:%.1f)\n", newOn, newOff);
} else {
humidityOnThreshold = newOn;
humidityOffThreshold = newOff;
Serial.printf(">> Thresholds updated — ON:%.1f%% OFF:%.1f%%\n",
humidityOnThreshold, humidityOffThreshold);
}
}
}
// ── MQTT message callback ──────────────────────────────────────
void onMQTTMessage(char* topic, byte* payload, unsigned int length) {
if (length > MQTT_MAX_PAYLOAD) {
Serial.printf(">> Command rejected — payload too large (%u bytes)\n", length);
return;
}
char message[MQTT_MAX_PAYLOAD + 1];
unsigned int copyLen = min(length, (unsigned int)MQTT_MAX_PAYLOAD);
memcpy(message, payload, copyLen);
message[copyLen] = '\0';
Serial.printf(">> Command received [%s]: %s\n", topic, message);
handleCommand(message);
}
// ── Non-blocking reconnect ────────────────────────────────────
void maintainConnections() {
if (millis() - lastReconnectAttempt < backoffDelay) return;
lastReconnectAttempt = millis();
if (WiFi.status() != WL_CONNECTED) {
Serial.printf(">> WiFi reconnecting (backoff: %lus)\n", backoffDelay / 1000);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
backoffDelay = min(backoffDelay * 2, (unsigned long)BACKOFF_MAX);
return;
}
if (!mqtt.connected()) {
Serial.printf(">> MQTT reconnecting (backoff: %lus)\n", backoffDelay / 1000);
char clientId[32];
getMQTTClientId(clientId, sizeof(clientId));
if (mqtt.connect(clientId, TOPIC_STATUS, 1, true, "offline")) {
mqtt.publish(TOPIC_STATUS, "online", true);
mqtt.subscribe(TOPIC_COMMAND);
Serial.println(">> MQTT reconnected");
backoffDelay = BACKOFF_MIN;
} else {
Serial.printf(">> MQTT failed RC=%d\n", mqtt.state());
backoffDelay = min(backoffDelay * 2, (unsigned long)BACKOFF_MAX);
}
return;
}
backoffDelay = BACKOFF_MIN;
}
// ── Humidifier AUTO control ────────────────────────────────────
void controlHumidifier(float humidity) {
if (humMode != AUTO) return;
if (!humidifierOn) {
if (humidity < humidityOnThreshold) {
if (!lowHumTimerActive) {
lowHumStartTime = millis();
lowHumTimerActive = true;
Serial.printf(">> Humidity %.1f%% below %.1f%% — 10s timer started\n",
humidity, humidityOnThreshold);
} else {
unsigned long elapsed = millis() - lowHumStartTime;
if (elapsed >= TRIGGER_DELAY) {
setHumidifier(true, "humidity below threshold for 10s");
lowHumTimerActive = false;
}
}
} else {
if (lowHumTimerActive) {
lowHumTimerActive = false;
Serial.println(">> Humidity recovered — timer reset");
}
}
} else {
if (humidity >= humidityOffThreshold) {
setHumidifier(false, "humidity reached off threshold");
}
}
}
// ── Fan AUTO intermittent timer control ───────────────────────
// Fan runs independently of sensor readings
// Continues normally even during sensor fault
// Timer restarts fresh when returning to AUTO from any override
void controlFan() {
if (fanMode != AUTO) return;
unsigned long elapsed = millis() - fanCycleStart;
if (!fanCyclePhase) {
// OFF phase
if (elapsed >= fanOffDuration) {
fanCyclePhase = true;
fanCycleStart = millis();
setFan(true, "fan AUTO cycle — ON phase");
}
} else {
// ON phase
if (elapsed >= fanOnDuration) {
fanCyclePhase = false;
fanCycleStart = millis();
setFan(false, "fan AUTO cycle — OFF phase");
}
}
}
// ── Sensor failsafe ───────────────────────────────────────────
// Humidifier: force OFF in AUTO mode on sensor fault
// Fan: continues normally — time driven, not sensor driven
void checkSensorFailsafe() {
if (millis() - lastValidReadTime <= SENSOR_TIMEOUT) return;
if (!sensorFaultActive) {
sensorFaultActive = true;
if (humMode == AUTO) {
setHumidifier(false, "sensor fault failsafe");
Serial.println(">> SENSOR FAULT — humidifier forced OFF");
if (mqtt.connected()) publishFault("sensor_timeout_auto_off");
} else if (humMode == FORCED_ON) {
Serial.println(">> SENSOR FAULT — humidifier stays ON (FORCED_ON)");
Serial.println(" WARNING: Operating blind");
if (mqtt.connected()) publishFault("sensor_timeout_override_active");
} else {
Serial.println(">> SENSOR FAULT — humidifier already OFF");
if (mqtt.connected()) publishFault("sensor_timeout_already_off");
}
// Fan intentionally NOT touched here — fan is time-driven
Serial.println(">> Fan continues normally during sensor fault");
}
}
// ── Sensor recovery ───────────────────────────────────────────
void handleSensorRecovery() {
if (!sensorFaultActive) return;
sensorFaultActive = false;
lowHumTimerActive = false;
Serial.println(">> Sensor recovered — resuming normal operation");
if (mqtt.connected()) publishFault("sensor_recovered");
}
// ══════════════════════════════════════════════════════════════
// BUTTON HANDLING
// Generic handler — debounce + edge detection + re-arm on release
// ══════════════════════════════════════════════════════════════
void handleSingleButton(ButtonState &btn, uint8_t pin, void (*onPress)()) {
unsigned long now = millis();
bool reading = digitalRead(pin);
if (reading != btn.lastState) {
btn.lastChange = now;
btn.lastState = reading;
}
if ((now - btn.lastChange) >= DEBOUNCE_MS) {
if (reading == LOW && btn.armed) {
onPress();
btn.armed = false;
}
if (reading == HIGH) btn.armed = true;
}
}
void handleButtons() {
// ── HUM AUTO ─────────────────────────────────────────────
handleSingleButton(btnHumAuto, BTN_HUM_AUTO, []() {
humMode = AUTO;
lowHumTimerActive = false;
Serial.println(">> HUM AUTO via button");
publishModeChange("humidifier", "button");
});
// ── HUM FORCE ON ─────────────────────────────────────────
handleSingleButton(btnHumForceOn, BTN_HUM_FORCE_ON, []() {
humMode = FORCED_ON;
setHumidifier(true, "button FORCE ON");
publishModeChange("humidifier", "button");
});
// ── HUM FORCE OFF ────────────────────────────────────────
handleSingleButton(btnHumForceOff, BTN_HUM_FORCE_OFF, []() {
humMode = FORCED_OFF;
setHumidifier(false, "button FORCE OFF");
publishModeChange("humidifier", "button");
});
// ── FAN AUTO ─────────────────────────────────────────────
handleSingleButton(btnFanAuto, BTN_FAN_AUTO, []() {
fanMode = AUTO;
fanCycleStart = millis(); // Restart cycle fresh
fanCyclePhase = false; // Start in OFF phase
setFan(false, "fan returning to AUTO — cycle reset");
Serial.println(">> FAN AUTO via button — cycle restarted");
publishModeChange("fan", "button");
});
// ── FAN FORCE ON ─────────────────────────────────────────
handleSingleButton(btnFanForceOn, BTN_FAN_FORCE_ON, []() {
fanMode = FORCED_ON;
setFan(true, "button FAN FORCE ON");
publishModeChange("fan", "button");
});
// ── FAN FORCE OFF ────────────────────────────────────────
handleSingleButton(btnFanForceOff, BTN_FAN_FORCE_OFF, []() {
fanMode = FORCED_OFF;
setFan(false, "button FAN FORCE OFF");
publishModeChange("fan", "button");
});
// ── SCREEN ───────────────────────────────────────────────
handleSingleButton(btnScreen, BTN_SCREEN, []() {
if (!sensorFaultActive) {
currentScreen = (currentScreen + 1) % 3;
lastButtonPress = millis();
Serial.printf(">> Screen → %d\n", currentScreen);
}
});
}
// ══════════════════════════════════════════════════════════════
// LED MANAGER
// HUM AUTO LED — ON when humidifier in AUTO mode
// FAN AUTO LED — ON when fan in AUTO mode
// FAN ON LED — ON when fan relay is physically ON
// FAULT LED — fast blink on sensor fault
// AUTO LED off = device is in manual override, check LCD
// ══════════════════════════════════════════════════════════════
void updateLEDs() {
unsigned long now = millis();
// ── AUTO mode indicators ─────────────────────────────────
digitalWrite(LED_HUM_AUTO, humMode == AUTO ? HIGH : LOW);
digitalWrite(LED_FAN_AUTO, fanMode == AUTO ? HIGH : LOW);
// ── Fan ON indicator — relay state ───────────────────────
digitalWrite(LED_FAN_ON, fanOn ? HIGH : LOW);
// ── Fault LED — fast blink ───────────────────────────────
if (sensorFaultActive) {
if (now - lastFaultBlinkTime >= FAULT_BLINK_MS) {
faultBlinkState = !faultBlinkState;
lastFaultBlinkTime = now;
digitalWrite(LED_FAULT, faultBlinkState ? HIGH : LOW);
}
} else {
digitalWrite(LED_FAULT, LOW);
}
}
// ══════════════════════════════════════════════════════════════
// LCD MANAGER
// Screen 0 — Environment + device states (default)
// Screen 1 — Control modes (HUM and FAN)
// Screen 2 — System status (WiFi, MQTT, uptime)
// Fault override — highest priority, ignores screen button
// Auto-return to Screen 0 after 15s inactivity
// No lcd.clear() — overwrites in place with padding
// ══════════════════════════════════════════════════════════════
void updateLCD() {
unsigned long now = millis();
if (now - lastLCDRefresh < LCD_REFRESH_MS) return;
lastLCDRefresh = now;
// Auto-return to Screen 0 after 15s inactivity
if (now - lastButtonPress > MANUAL_PAUSE_MS) {
currentScreen = 0;
}
// ── Fault override — highest priority ────────────────────
if (sensorFaultActive) {
lcdPrintPadded(0, 0, "SENSOR ERROR");
lcdPrintPadded(0, 1, "Check DHT22");
return;
}
char line1[17];
char line2[17];
switch (currentScreen) {
// ── Screen 0 — Environment ───────────────────────────
case 0: {
snprintf(line1, sizeof(line1),
"T:%4.1fC H:%4.1f%%",
currentTemp, currentHumidity);
snprintf(line2, sizeof(line2),
"HUM:%-3s FAN:%-3s",
humidifierOn ? "ON" : "OFF",
fanOn ? "ON" : "OFF");
lcdPrintPadded(0, 0, line1);
lcdPrintPadded(0, 1, line2);
break;
}
// ── Screen 1 — Control Modes ─────────────────────────
case 1: {
snprintf(line1, sizeof(line1),
"HUM:%s",
humMode == AUTO ? "AUTO" :
humMode == FORCED_ON ? "FORCED ON" : "FORCED OFF");
snprintf(line2, sizeof(line2),
"FAN:%s",
fanMode == AUTO ? "AUTO" :
fanMode == FORCED_ON ? "FORCED ON" : "FORCED OFF");
lcdPrintPadded(0, 0, line1);
lcdPrintPadded(0, 1, line2);
break;
}
// ── Screen 2 — System Status ─────────────────────────
case 2: {
snprintf(line1, sizeof(line1),
"WiFi:%s MQTT:%s",
WiFi.status() == WL_CONNECTED ? "OK" : "--",
mqtt.connected() ? "OK" : "--");
unsigned long upSec = (now - bootTime) / 1000;
unsigned long upHour = upSec / 3600;
unsigned long upMin = (upSec % 3600) / 60;
unsigned long upS = upSec % 60;
snprintf(line2, sizeof(line2),
"UP:%02lu:%02lu:%02lu",
upHour, upMin, upS);
lcdPrintPadded(0, 0, line1);
lcdPrintPadded(0, 1, line2);
break;
}
}
}
void setup() {
Serial.begin(BAUD_RATE);
delay(1000);
bootTime = millis();
// ── Watchdog ────────────────────────────────────────────
esp_task_wdt_deinit();
esp_task_wdt_config_t wdt_config = {
.timeout_ms = 60000,
.idle_core_mask = 0,
.trigger_panic = true
};
esp_task_wdt_init(&wdt_config);
esp_task_wdt_add(NULL);
Serial.println("Watchdog registered (60s timeout)");
// ── Relay pins ───────────────────────────────────────────
// Always OFF on boot — sensor/timer decides state
pinMode(HUMIDIFIER_PIN, OUTPUT);
pinMode(FAN_PIN, OUTPUT);
digitalWrite(HUMIDIFIER_PIN, RELAY_OFF);
digitalWrite(FAN_PIN, RELAY_OFF);
Serial.println("Humidifier OFF on boot");
Serial.println("Fan OFF on boot");
// ── Button pins ──────────────────────────────────────────
// GPIO32/33/25/26 — internal pullup
// GPIO34/35/39 — input only, no internal pullup
// External 10kΩ pullup to 3.3V required
pinMode(BTN_HUM_AUTO, INPUT_PULLUP);
pinMode(BTN_HUM_FORCE_ON, INPUT_PULLUP);
pinMode(BTN_HUM_FORCE_OFF, INPUT_PULLUP);
pinMode(BTN_FAN_AUTO, INPUT_PULLUP); // GPIO14 — internal pullup
pinMode(BTN_FAN_FORCE_ON, INPUT_PULLUP); // GPIO17 — internal pullup
pinMode(BTN_FAN_FORCE_OFF, INPUT); // GPIO39 — external 10kΩ pullup
pinMode(BTN_SCREEN, INPUT_PULLUP);
// ── LED pins ─────────────────────────────────────────────
pinMode(LED_HUM_AUTO, OUTPUT);
pinMode(LED_FAN_AUTO, OUTPUT);
pinMode(LED_FAN_ON, OUTPUT);
pinMode(LED_FAULT, OUTPUT);
// ── Startup LED test — all ON 400ms ─────────────────────
digitalWrite(LED_HUM_AUTO, HIGH);
digitalWrite(LED_FAN_AUTO, HIGH);
digitalWrite(LED_FAN_ON, HIGH);
digitalWrite(LED_FAULT, HIGH);
delay(400);
digitalWrite(LED_HUM_AUTO, LOW);
digitalWrite(LED_FAN_AUTO, LOW);
digitalWrite(LED_FAN_ON, LOW);
digitalWrite(LED_FAULT, LOW);
Serial.println("LED test complete");
// ── LCD ──────────────────────────────────────────────────
Wire.begin();
lcd.init();
lcd.backlight();
lcd.setCursor(0, 0);
lcd.print("Greenhouse v3.1 ");
lcd.setCursor(0, 1);
lcd.print("Initializing... ");
Serial.println("LCD initialized");
// ── DHT22 ────────────────────────────────────────────────
dht.begin();
lastValidReadTime = millis();
// ── Fan timer init ───────────────────────────────────────
fanCycleStart = millis();
fanCyclePhase = false; // Start in OFF phase
// ── WiFi with 15s timeout ───────────────────────────────
Serial.printf("Connecting to WiFi: %s\n", WIFI_SSID);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
unsigned long wifiStart = millis();
while (WiFi.status() != WL_CONNECTED) {
esp_task_wdt_reset();
if (millis() - wifiStart > WIFI_TIMEOUT) {
Serial.println("\nWiFi timeout — continuing.");
break;
}
delay(500);
Serial.print(".");
}
if (WiFi.status() == WL_CONNECTED) {
Serial.printf("\nWiFi connected — IP: %s\n", WiFi.localIP().toString().c_str());
}
// ── MQTT with 15s timeout ───────────────────────────────
if (WiFi.status() == WL_CONNECTED) {
mqtt.setServer(MQTT_BROKER, MQTT_PORT);
mqtt.setCallback(onMQTTMessage);
char clientId[32];
getMQTTClientId(clientId, sizeof(clientId));
Serial.printf("Connecting to MQTT as: %s\n", clientId);
unsigned long mqttStart = millis();
while (!mqtt.connected()) {
esp_task_wdt_reset();
if (millis() - mqttStart > MQTT_TIMEOUT) {
Serial.println("MQTT timeout — continuing.");
break;
}
esp_task_wdt_reset();
bool connected = mqtt.connect(clientId, TOPIC_STATUS, 1, true, "offline");
esp_task_wdt_reset();
if (connected) {
mqtt.publish(TOPIC_STATUS, "online", true);
mqtt.subscribe(TOPIC_COMMAND);
Serial.println("MQTT connected");
backoffDelay = BACKOFF_MIN;
break;
}
esp_task_wdt_reset();
delay(1000);
Serial.print(".");
}
}
// ── Ready ────────────────────────────────────────────────
lcd.setCursor(0, 0);
lcd.print("System Ready ");
lcd.setCursor(0, 1);
lcd.print(" ");
delay(800);
Serial.println("--------------------------------------------------");
Serial.println("Version 3.1 — ESP32 Greenhouse Controller");
Serial.printf("Hum thresholds — ON:%.1f%% OFF:%.1f%%\n",
humidityOnThreshold, humidityOffThreshold);
Serial.printf("Fan cycle — ON:%lus OFF:%lus\n",
fanOnDuration / 1000, fanOffDuration / 1000);
Serial.println("Humidifier mode : AUTO");
Serial.println("Fan mode : AUTO (starting in OFF phase)");
Serial.println("Buttons : GPIO32/33/25/14/17/39/26");
Serial.println("LEDs : GPIO19(HUM AUTO) GPIO16(FAN AUTO) GPIO23(FAN ON) GPIO27(FAULT)");
Serial.println("Relays : GPIO18(HUM) GPIO13(FAN)");
Serial.println("LCD I2C : SDA=GPIO21 SCL=GPIO22");
Serial.println("--------------------------------------------------");
}
void loop() {
esp_task_wdt_reset();
maintainConnections();
if (mqtt.connected()) mqtt.loop();
checkSensorFailsafe();
handleButtons();
updateLEDs();
updateLCD();
controlFan(); // Fan timer runs every loop — independent of sensor
if (millis() - lastReadTime < READ_INTERVAL) return;
lastReadTime = millis();
float humidity = dht.readHumidity();
float tempC = dht.readTemperature();
if (isnan(humidity) || isnan(tempC) ||
humidity == 0 || tempC == 0 ||
humidity < 1.0 || humidity > 99.9 ||
tempC < 5.0 || tempC > 60.0) {
Serial.printf("WARNING: Reading rejected — Temp:%.2f Humidity:%.2f\n",
tempC, humidity);
return;
}
currentTemp = tempC;
currentHumidity = humidity;
lastValidReadTime = millis();
handleSensorRecovery();
Serial.printf(
"Temp:%.2f°C Hum:%.2f%% HUM:%s(%s) FAN:%s(%s)\n",
tempC, humidity,
humidifierOn ? "ON" : "OFF",
humMode == AUTO ? "AUTO" : humMode == FORCED_ON ? "F-ON" : "F-OFF",
fanOn ? "ON" : "OFF",
fanMode == AUTO ? "AUTO" : fanMode == FORCED_ON ? "F-ON" : "F-OFF"
);
controlHumidifier(humidity);
if (mqtt.connected()) {
bool tempChanged = fabs(tempC - lastPublishedTemp) >= PUBLISH_DELTA;
bool humChanged = fabs(humidity - lastPublishedHumidity) >= PUBLISH_DELTA;
bool humStateChanged = humidifierOn != lastPublishedHumState;
bool fanStateChanged = fanOn != lastPublishedFanState;
bool heartbeatElapsed = (millis() - lastTelemetryPublish) >= HEARTBEAT_MAX;
if (tempChanged || humChanged || humStateChanged || fanStateChanged || heartbeatElapsed) {
publishTelemetry(tempC, humidity);
}
if (millis() - lastSystemPublish >= SYSTEM_INTERVAL) {
publishSystem();
}
}
}
Humidifier
Exhaust Fan
Reset
AUTO
FORCE ON
FORCE OFF
HUMIDIFIER
DISPLAY
EXHAUST FAN
AUTO
FORCE ON
FORCE OFF
HUMIDIFIER
AUTO
AUTO
EXHAUST FAN
Fault