// FILOSOFÍA DE CONTROL:
// PARAMETROS IDEALES:
// - TEMPERATURA: 15°C - 21°C
// - HUMEDAD: 60% - 80%
// Ideal: 15-20°C para crecimiento óptimo.
// Germinación: 18-20°C
// Límite superior: Temperaturas por encima de 21-27°C inducen el espigado (floración), >32°C causa estrés y quemaduras solares.
// Límite inferior: Crece lentamente por debajo de 6°C, se detiene por debajo de 0°C, y las heladas la dañan.
// VPD (déficit de presión de vapor)
// 🔴 Peligroso bajo: < 0.4 kPa
// 🟢 Óptimo: 0.6 – 1.0 kPa
// 🔴 Alto: > 1.2 kPa
// ---------------- LIBRERIAS ----------------
#include <Arduino.h>
#include <Adafruit_SHT4x.h>
#include <esp_task_wdt.h>
#include <RTClib.h>
#include <SPI.h>
#include <SD.h>
#include <WiFi.h>
#include <WebServer.h>
// TIEMPO DE ESPERA DE WATCHDOG (seg)
#define WDT_TIMEOUT 15
// ---------------- CONFIGURACION PERIFERICOS (OBJETOS) ----------------
Adafruit_SHT4x sht4 = Adafruit_SHT4x();
RTC_DS3231 rtc;
#define SD_CS 5
// ---------------- VARIABLES DE SENSORES ----------------
float temp = 0.0;
float hum = 0.0;
int errorSensorCount = 0; // Contador de errores
// ---------------- VPD ----------------
float calcularVPD(float temp, float hum) {
float es = 0.6108 * exp((17.27 * temp) / (temp + 237.3));
float ea = es * (hum / 100.0);
return es - ea; // kPa
}
// ---------------- TUS PARÁMETROS (LECHUGAS NFT) ----------------
// VPD
const float VPD_OPT_MIN = 0.6; // Limite Inferior Optimo
const float VPD_OPT_MAX = 1.0; // Limite Superior Optimo
const float VPD_PELIGRO_BAJO = 0.4; //
const float VPD_PELIGRO_ALTO = 1.2; //
// Temperatura
const float TEMP_MIN_SEGURA = 15.0; //
const float TEMP_MAX_SEGURA = 21.0; //
const float TEMP_EMERGENCIA = 27.0; // Floracion y Espigado
const float TEMP_EMERGENCIA_OFF = 25.5;
// Humedad
const float HUM_MAX_SEGURA = 80.0;
const float HUM_HONGOS = 85.0;
// Histeresis VPD
const float VPD_NEB_ON = 1.05;
const float VPD_NEB_OFF = 0.90;
const float VPD_EXT_ON = 0.50;
const float VPD_EXT_OFF = 0.65;
// ---------------- PINES ----------------
const int PIN_EXTRACTOR = 25;
const int PIN_NEBULIZADOR = 26;
// ---------------- ESTADOS ----------------
bool extractorON = false;
bool nebulizadorON = false;
bool emergenciaActiva = false;
bool fallbackExtractorState = false;
bool sensorEnFallo = false;
enum EstadoSistema {
EST_OK,
EST_VENTILANDO,
EST_NEBULIZANDO,
EST_CONTROL_INTENSO,
EST_EMERGENCIA,
EST_SENSOR_FAIL
};
EstadoSistema estadoActual = EST_OK;
EstadoSistema estadoAnterior = EST_OK;
unsigned long estadoStartTime = 0;
// ---------------- VARIABLES SD ----------------
File logFile;
bool lastExtractorON = false;
bool lastNebulizadorON = false;
bool sdDisponible = false;
char nombreArchivo[20]; // "YYYY-MM-DD.txt"
int ultimaHoraLog = -1;
// ---------------- VARIABLES ----------------
unsigned long tiempoEstadoMin = 0;
unsigned long ultimoClienteTime = 0;
const unsigned long WIFI_IDLE_TIMEOUT = 5 * 60 * 1000;
bool wifiDormido = false;
// ---------------- ESTACION ----------------
bool esVerano() {
DateTime now = rtc.now(); // Lee la fecha actual del RTC
int mes = now.month(); // Obtiene el mes (1=enero, 12=diciembre)
// Verano: diciembre a marzo
if (mes == 12 || mes == 1 || mes == 2 || mes == 3) {
return true; // Es verano
} else {
return false; // Invierno o transición
}
}
// ---------------- TEMPORIZADORES (MILLIS) ----------------
unsigned long tiempoAnterior = 0;
const long INTERVALO = 30000; // Analizar cada 30 segundos
unsigned long extractorStartTime = 0;
unsigned long nebulizadorStartTime = 0;
unsigned long extractorLastChange = 0;
unsigned long nebulizadorLastChange = 0;
unsigned long sensorFailStart = 0;
const long MAX_NEB_TIME = 3600000; // 60 Minutos
const long MIN_ON_TIME = 300000; // 5 Minutos minimo de encendido para maquinas
const long MIN_OFF_TIME = 180000; // 3 Minutos minimo de apagado
const unsigned long MAX_SENSOR_FAIL = 6UL * 60UL * 60UL * 1000UL; // 6 Horas
// ---------------- GENERAR ARCHIVO SD DIARIO ----------------
void actualizarNombreArchivo() {
DateTime now = rtc.now();
snprintf(
nombreArchivo,
sizeof(nombreArchivo),
"/%04d-%02d-%02d.txt",
now.year(),
now.month(),
now.day()
);
}
// ---------------- WiFi Access Point ----------------
const char* ssid = "Room";
const char* password = "invernadero479"; // mínimo 8 chars
WebServer server(80);
void handleRoot ();
void handleData ();
// ---------------- SETUP ----------------
void setup() {
Serial.begin(115200);
pinMode(PIN_EXTRACTOR, OUTPUT);
pinMode(PIN_NEBULIZADOR, OUTPUT);
// Iniciar todo apagado por seguridad
digitalWrite(PIN_EXTRACTOR, LOW);
digitalWrite(PIN_NEBULIZADOR, LOW);
// --- INICIO DE WIFI ---
WiFi.mode(WIFI_AP);
WiFi.softAP(ssid, password);
WiFi.setTxPower(WIFI_POWER_15dBm);
WiFi.setSleep(false);
Serial.print("WiFi AP iniciado. IP: ");
Serial.println(WiFi.softAPIP());
// --- INICIO DE SENSOR SHT40 ---
if (!sht4.begin()) {
Serial.println("No se encuentra el SHT40");
}
sht4.setPrecision(SHT4X_HIGH_PRECISION); // Alta precisión para SHT40
sht4.setHeater(SHT4X_NO_HEATER); // Sin heater interno
// --- INICIO DE RTC ---
if (!rtc.begin()) {
Serial.println("No se encontro el RTC! Verifica conexiones");
}
// --- INICIO DE SD ---
if (!SD.begin(SD_CS)) {
Serial.println("SD No Disponible");
sdDisponible = false;
} else {
Serial.println("SD OK");
sdDisponible = true;
actualizarNombreArchivo();
logFile = SD.open(nombreArchivo, FILE_APPEND);
if (logFile) {
logFile.println("----- INICIO SISTEMA -----");
logFile.close();
}
}
// --- INICIO DE WATCHDOG ---
esp_task_wdt_config_t twdt_config = {
.timeout_ms = WDT_TIMEOUT * 1000,
.idle_core_mask = 0,
.trigger_panic = true
};
esp_task_wdt_reconfigure(&twdt_config);
esp_task_wdt_add(NULL);
// Iniciar Server
server.on("/", handleRoot);
server.on("/data", handleData);
server.begin();
}
// ---------------- LOOP ----------------
void loop() {
// --- RESET DEL WATCHDOG ---
esp_task_wdt_reset();
unsigned long tiempoActual = millis();
// Ejecutar lógica cada 30 segundos sin bloquear el chip
if (tiempoActual - tiempoAnterior >= INTERVALO) {
tiempoAnterior = tiempoActual;
bool verano = esVerano(); // Define estacion climatica
DateTime now = rtc.now();
int horaAmanecer;
int horaAtardecer;
if (verano) {
horaAmanecer = 8;
horaAtardecer = 20;
}
else {
horaAmanecer = 9;
horaAtardecer = 17;
}
bool esDia = (now.hour() >= horaAmanecer && now.hour() <= horaAtardecer);
bool extractorShouldOn = false;
bool nebulizadorShouldOn = false;
// 1. LECTURA SENSOR
sensors_event_t humidity, temp_event;
sht4.getEvent(&humidity, &temp_event);
temp = temp_event.temperature;
hum = humidity.relative_humidity;
float vpd = NAN;
if (!isnan(temp) && !isnan(hum)) {
vpd = calcularVPD(temp, hum);
}
// 2. PROTECCIÓN DE LECTURA ERRÓNEA
if (isnan(temp) || isnan(hum)) {
sensorEnFallo = true;
estadoActual = EST_SENSOR_FAIL;
emergenciaActiva = false;
Serial.println("Error sensor!");
if (sensorFailStart == 0)
sensorFailStart = millis();
// Tras 10 minutos sin sensor...
if (millis() - sensorFailStart > 600000) { // 10 Min
if (millis() - extractorLastChange >= 300000) {
fallbackExtractorState = !fallbackExtractorState; // 5 Min
extractorLastChange = millis();
logEvento("SISTEMA", fallbackExtractorState, NAN, NAN);
}
extractorON = fallbackExtractorState;
nebulizadorON = false;
}
if (millis() - sensorFailStart > MAX_SENSOR_FAIL) {
extractorON = true; // Ventilación permanente
nebulizadorON = false;
}
digitalWrite(PIN_EXTRACTOR, extractorON ? HIGH : LOW);
digitalWrite(PIN_NEBULIZADOR, LOW);
}
else {
sensorFailStart = 0; //Vuelve a funcionar sensor
sensorEnFallo = false;
}
if (!sensorEnFallo) {
// ---------------- LÓGICA DE CONTROL ----------------
// ================== EMERGENCIAS ==================
// Calor extremo → todo lo que enfríe
if (temp >= TEMP_EMERGENCIA) {
emergenciaActiva = true;
}
else if (temp <= TEMP_EMERGENCIA_OFF){
emergenciaActiva = false;
}
if (emergenciaActiva) {
extractorShouldOn = true;
// En emergencia termica, priorizar enfriamiento
// Nebuliza aunque la HR sea alta, salvo saturacion extrema
if (esDia && hum < 95.0) {
nebulizadorShouldOn = true;
} else {
nebulizadorShouldOn = false;
}
}
else if (hum >= HUM_HONGOS) {
extractorShouldOn = true;
nebulizadorShouldOn = false;
}
else if (temp <= TEMP_MIN_SEGURA) {
extractorShouldOn = false;
nebulizadorShouldOn = false;
}
else {
// ================== CONTROL POR VPD ==================
// --- VPD DEMASIADO BAJO (aire saturado) ---
if (vpd < VPD_PELIGRO_BAJO) {
extractorShouldOn = true;
nebulizadorShouldOn = false;
}
// --- VPD BAJO ---
else if (vpd < VPD_EXT_ON) {
extractorShouldOn = true;
}
// --- VPD ÓPTIMO ---
else if (vpd >= VPD_OPT_MIN && vpd <= VPD_OPT_MAX) {
extractorShouldOn = extractorON;
nebulizadorShouldOn = nebulizadorON;
}
// --- VPD ALTO (aire seco) ---
else if (vpd > VPD_NEB_ON && vpd <= VPD_PELIGRO_ALTO) {
nebulizadorShouldOn = esDia;
extractorShouldOn = false;
}
// --- VPD MUY ALTO (estrés hídrico) ---
else if (vpd > VPD_PELIGRO_ALTO) {
nebulizadorShouldOn = esDia;
extractorShouldOn = false; // no secar más
}
}
// ================== HISTERESIS ==================
// Apagar extractor solo si ambiente ya es seguro
if (!emergenciaActiva && extractorON && vpd >= VPD_EXT_OFF && temp <= TEMP_MAX_SEGURA && hum < HUM_MAX_SEGURA && millis() - extractorStartTime >= MIN_ON_TIME) {
extractorShouldOn = false;
}
// Apagar nebulización si VPD ya bajó
if (nebulizadorON && vpd <= VPD_NEB_OFF && millis() - nebulizadorStartTime >= MIN_ON_TIME) {
nebulizadorShouldOn = false;
}
// ================== TIEMPO MÍNIMO ON ==================
if (extractorShouldOn && !extractorON && millis() - extractorLastChange >= MIN_OFF_TIME) {
extractorON = true;
extractorStartTime = millis();
extractorLastChange = millis();
}
else if (!extractorShouldOn && extractorON && !emergenciaActiva && millis() - extractorStartTime >= MIN_ON_TIME) {
extractorON = false;
extractorLastChange = millis();
}
if (nebulizadorShouldOn && !nebulizadorON && millis() - nebulizadorLastChange >= MIN_OFF_TIME) {
nebulizadorON = true;
nebulizadorStartTime = millis();
nebulizadorLastChange = millis();
}
else if (!nebulizadorShouldOn && nebulizadorON && millis() - nebulizadorStartTime >= MIN_ON_TIME) {
nebulizadorON = false;
nebulizadorLastChange = millis();
}
if (nebulizadorON && millis() - nebulizadorStartTime >= MAX_NEB_TIME) {
nebulizadorON = false;
nebulizadorLastChange = millis();
logEvento("NEB_TIMEOUT", false, temp, hum);
}
// ================== LOG DE EVENTOS ==================
if (extractorON != lastExtractorON) {
logEvento("EXT", extractorON, temp, hum);
lastExtractorON = extractorON;
}
if (nebulizadorON != lastNebulizadorON) {
logEvento("NEB", nebulizadorON, temp, hum);
lastNebulizadorON = nebulizadorON;
}
// ===== LOG HORARIO (SNAPSHOT) =====
if (now.hour() != ultimaHoraLog) {
ultimaHoraLog = now.hour();
logSnapshot(temp, hum, extractorON, nebulizadorON);
}
// 3. SALIDAS FÍSICAS
digitalWrite(PIN_EXTRACTOR, extractorON ? HIGH : LOW);
digitalWrite(PIN_NEBULIZADOR, nebulizadorON ? HIGH : LOW);
// -------- DETERMINAR ESTADO --------
if (emergenciaActiva)
estadoActual = EST_EMERGENCIA;
else if (extractorON && nebulizadorON)
estadoActual = EST_CONTROL_INTENSO;
else if (extractorON)
estadoActual = EST_VENTILANDO;
else if ( nebulizadorON)
estadoActual = EST_NEBULIZANDO;
else
estadoActual = EST_OK;
// -------- TIEMPO EN ESTADO ACTUAL --------
if (estadoActual != estadoAnterior) {
estadoStartTime = millis();
estadoAnterior = estadoActual;
}
tiempoEstadoMin = (millis() - estadoStartTime) / 60000;
// 4. MONITOR SERIAL
Serial.print("Temp: "); Serial.print(temp);
Serial.print(" | Hum: "); Serial.print(hum);
Serial.print(" || EXT: "); Serial.print(extractorON);
Serial.print(" | NEB: "); Serial.print(nebulizadorON);
Serial.print(" | VPD: "); Serial.print(vpd, 2);
Serial.print(" | Verano: "); Serial.println(verano ? "SI" : "NO");
}
}
server.handleClient();
int clientes = WiFi.softAPgetStationNum();
if (clientes > 0) {
ultimoClienteTime = millis();
if (wifiDormido) {
WiFi.setSleep(false);
wifiDormido = false;
Serial.println("WiFi Despierto");
}
}
else {
if (!wifiDormido && millis() - ultimoClienteTime > WIFI_IDLE_TIMEOUT) {
WiFi.setSleep(true);
wifiDormido = true;
Serial.println("WiFi Modo Sleep");
}
}
}
// ================== SD LOG POR EVENTOS ==================
void logEvento(const char* dispositivo, bool estado, float temp, float hum) {
if (!sdDisponible) return;
actualizarNombreArchivo();
DateTime now = rtc.now();
logFile = SD.open(nombreArchivo, FILE_APPEND);
if (!logFile) return;
// PROTECCION CONTRA NaN
if (isnan(temp) || isnan(hum)) {
logFile.printf(
"%04d-%02d-%02d %02d:%02d | %s | SENSOR ERROR\n"
now.year(), now.month(), now.day(),
now.hour(), now.minute(),
dispositivo
);
logFile.close();
return;
}
float vpd = calcularVPD(temp, hum);
if (logFile) {
logFile.printf(
"%04d-%02d-%02d %02d:%02d | %s %s | T=%.1f | H=%.0f | VPD=%.2f\n",
now.year(), now.month(), now.day(),
now.hour(), now.minute(),
dispositivo, estado ? "ON" : "OFF",
temp, hum, vpd
);
logFile.close();
}
}
// ================== SD LOG SNAPSHOT ==================
void logSnapshot(float temp, float hum, bool ext, bool neb) {
if (!sdDisponible) return;
actualizarNombreArchivo();
DateTime now = rtc.now();
logFile = SD.open(nombreArchivo, FILE_APPEND);
if (!logFile) return;
// PROTECCION CONTRA NaN
if (isnan(temp) || isnan(hum)) {
logFile.printf(
"%04d-%02d-%02d %02d:%02d | %s | SENSOR ERROR\n"
now.year(), now.month(), now.day(),
now.hour(), now.minute()
);
logFile.close();
return;
}
float vpd = calcularVPD(temp, hum);
if (logFile) {
logFile.printf(
"%04d-%02d-%02d %02d:00 | SNAPSHOT | T=%.1f | H=%.0f | VPD=%.2f | EXT=%d | NEB=%d\n",
now.year(), now.month(), now.day(),
now.hour(),
temp, hum, vpd,
ext, neb
);
logFile.close();
}
}
String estadoTexto() {
if (sensorEnFallo) return "FALLO SENSOR (MODO SEGURO)";
if (emergenciaActiva) return "EMERGENCIA TERMICA";
if (extractorON && nebulizadorON) return "CONTROL VPD INTENSO";
if (extractorON) return "VENTILANDO";
if (nebulizadorON) return "NEBULIZANDO";
return "ESTADO OK";
}
// ================== WebServer ==================
void handleRoot() {
String html = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Invernadero NFT</title>
<style>
body {
font-family: system-ui;
background: #020617;
color: #e5e7eb;
margin: 0;
padding: 20px;
}
.card {
background: #0f172a;
border-radius: 16px;
padding: 16px;
margin-bottom: 12px;
box-shadow: 0 0 10px #000;
}
h1 { color: #22c55e; }
.value { font-size: 1.6em; }
.small { opacity: 0.7; }
</style>
</head>
<body>
<h1>🌱 Invernadero NFT</h1>
<div class="card">🌡 Temp: <span id="temp" class="value">--</span> °C</div>
<div class="card">💧 Hum: <span id="hum" class="value">--</span> %</div>
<div class="card">📉 VPD: <span id="vpd" class="value">--</span> kPa</div>
<div class="card">⚙ Estado: <span id="estado">--</span></div>
<div class="card">⏱ Tiempo: <span id="tiempo">--</span> min</div>
<div class="card small">📆 <span id="estacion">--</span></div>
<script>
function actualizar() {
fetch('/data')
.then(r => r.json())
.then(d => {
document.getElementById('temp').textContent = d.temp;
document.getElementById('hum').textContent = d.hum;
document.getElementById('vpd').textContent = d.vpd;
document.getElementById('estado').textContent = d.estado;
document.getElementById('tiempo').textContent = d.tiempo;
document.getElementById('estacion').textContent = d.estacion;
});
}
setInterval(actualizar, 15000);
actualizar();
</script>
</body>
</html>
)rawliteral";
server.send(200, "text/html", html);
}
void handleData() {
float vpd = NAN;
if (!isnan(temp) && !isnan(hum)) {
vpd = calcularVPD(temp, hum);
}
String json = "{";
json += "\"temp\":" + String(temp, 1) + ",";
json += "\"hum\":" + String(hum, 0) + ",";
json += "\"vpd\":" + (isnan(vpd) ? String("null") : String(vpd, 2)) + ",";
json += "\"estado\":\"" + estadoTexto() + "\",";
json += "\"tiempo\":" + String(tiempoEstadoMin) + ",";
json += "\"estacion\":\"" + String(esVerano() ? "VERANO" : "INVIERNO") + "\"";
json += "}";
server.send(200, "application/json", json);
}