#include <Arduino.h> // Librería base de Arduino para ESP32
#include <WiFi.h> // Librería para gestionar la conexión Wi-Fi
#include <WebServer.h> // Librería para crear el servidor web local
#include <ESPmDNS.h> // Librería para permitir el acceso por nombre (http://control.local)
#include "time.h" // Librería nativa del sistema para manejo de tiempo y fecha
#include <ESP_Mail_Client.h> // Librería avanzada para el envío de correos con adjuntos
#include <LiquidCrystal.h> // Librería para la pantalla LCD
// ============================================================================
// 1. SECCIÓN DE VARIABLES GLOBALES Y CONFIGURACIONES
// ============================================================================
// --- CONFIGURACIÓN DE LA RED WI-FI ---
const char* ssid = "Wokwi-GUEST"; // Nombre de tu red Wi-Fi
const char* password = ""; // Contraseña de tu red Wi-Fi
// --- CONFIGURACIÓN DEL REMITENTE DEL CORREO (GMAIL) ---
#define SMTP_HOST "smtp.gmail.com"
#define SMTP_PORT 465
#define REPOSITORIO_CORREO "[email protected]"
#define REPOSITORIO_PASS "bqjzfglbjvlxvbfd"
#define CORREO_DESTINO "[email protected]"
// --- CONFIGURACIÓN DEL RELOJ EN LÍNEA (NTP) ---
const char* horaGoogle = "time.google.com";
const long gmtOffset_sec = -14400;
const int daylightOffset_sec = 0;
bool reporteEnviadoHoy = false;
// --- CONFIGURACIÓN DE PANTALLA LCD (RS, E, D4, D5, D6, D7) ---
LiquidCrystal lcd(22, 23, 5, 18, 19, 21);
// --- ASIGNACIÓN DE PINES DE ENTRADA (SENSORES Y PULSADOR) ---
int I1 = 32; // Pin analógico para el Sensor 1: Medición de Presión
int I2 = 33; // Pin analógico para el Sensor 2: Medición de Nivel
const int PIN_PULSADOR_REARME = 4; // Pin digital para el pulsador físico de rearme
// --- ASIGNACIÓN DE PINES DE SALIDA (ACTUADORES Y LEDS) ---
int O1 = 13; // Pin digital para Alarma LED 1: Alerta crítica de Presión
int O2 = 12; // Pin digital para Alarma LED 2: Alerta de Nivel Bajo
int O4 = 2; // Pin digital para el LED integrado: Indicador de estado Wi-Fi
int O5 = 26; // Pin digital para Alarma Sonora (Buzzer)
int O6 = 27; // Pin digital para Control de Bomba (Relé)
// --- VARIABLES DE LECTURA BRUTA ---
int EstadoI1 = 0;
int EstadoI2 = 0;
// --- VARIABLES CONVERTIDAS A UNIDADES INDUSTRIALES ---
float presionPSI = 0.0;
float nivelMetros = 0.0;
// --- VARIABLES DE ESTADO Y MODO DE FUNCIONAMIENTO ---
bool modoOnline = false;
unsigned long tiempoPulsadorAnterior = 0;
unsigned long tiempoUltimoPrint = 0; // Control de tiempo para el Monitor Serie
// --- VARIABLES DE TIEMPO PARA CONTROL DE PARPADEO (MILLIS) ---
unsigned long tiempoO1 = 0;
unsigned long tiempoO2 = 0;
unsigned long tiempoParpadeo = 0;
// --- VARIABLES DE TIEMPO PARA ACTUADORES ---
unsigned long tiempoInicioCritico = 0;
unsigned long tiempoNormalizacion = 0;
bool enEstadoCritico = false;
bool bombaBloqueada = false;
bool alarmaSonoraActiva = false;
// --- VARIABLES DE ESTADO DE SALIDAS ENERGIZADAS ---
int estadoO1 = LOW;
int estadoO2 = LOW;
// --- CONFIGURACIÓN DE SETPOINTS Y LÍMITES CRÍTICOS ---
int Limitedesensores = 4000;
int UmbralRetardo = 3000;
int Precausion = 2000;
// --- UMBRALES PARA NIVEL BAJO ---
int umbralNivelBajo = 1000;
int umbralNivelPrecaucion = 2000;
// --- CONTROL INTERNO ---
bool servidorIniciado = false;
int ultimoMinutoRegistrado = -1;
bool exitoEnvioCorreo = false;
// --- VARIABLES DE TIEMPO Y FORMATOS ---
struct tm timeinfo;
int horaActual = 0;
int minutoActual = 0;
int diaActual = 0;
int mesActual = 0;
int anioActual = 0;
char horaFormateada[9];
char fechaFormateada[12];
char filaCSV[80];
char bufferFilaWeb[6];
char bufferFechaWeb[12];
String datosCSV = "";
String textoMensaje = "";
String paginaHTML = "";
String jsonResponse = "";
int idx = 0;
// --- HISTORIAL FIFO ---
struct Registro {
uint8_t dia; uint8_t mes; uint16_t anio;
uint8_t hora; uint8_t minuto;
float presion; float nivel;
};
const int MAX_REGISTROS = 1440;
Registro historial[MAX_REGISTROS];
int totalRegistros = 0;
// --- LIBRERÍAS DE RED ---
SMTPSession smtp;
WebServer server(80);
Session_Config config;
SMTP_Message message;
SMTP_Attachment att;
// ============================================================================
// 2. FUNCIONES DE OPERACIÓN Y SISTEMA
// ============================================================================
void agregarAlHistorial(uint8_t d, uint8_t m, uint16_t a, uint8_t h, uint8_t min, float p, float n) {
if (totalRegistros < MAX_REGISTROS) {
historial[totalRegistros] = {d, m, a, h, min, p, n};
totalRegistros++;
} else {
for (idx = 0; idx < MAX_REGISTROS - 1; idx++) {
historial[idx] = historial[idx + 1];
}
historial[MAX_REGISTROS - 1] = {d, m, a, h, min, p, n};
}
}
String obtenerHoraActual() {
if(!getLocalTime(&timeinfo)){ return "--:--:--"; }
strftime(horaFormateada, sizeof(horaFormateada), "%H:%M:%S", &timeinfo);
return String(horaFormateada);
}
String obtenerFechaActual() {
if(!getLocalTime(&timeinfo)){ return "--/--/----"; }
strftime(fechaFormateada, sizeof(fechaFormateada), "%d/%m/%Y", &timeinfo);
return String(fechaFormateada);
}
bool enviarReportePorCorreo(String motivo) {
if (!modoOnline) return false;
Serial.println("Generando archivo Excel (.csv)...");
datosCSV = "Fecha,Hora,Presion (PSI),Nivel (m)\n";
for (idx = 0; idx < totalRegistros; idx++) {
sprintf(filaCSV, "%02d/%02d/%04d,%02d:%02d,%.2f,%.2f\n",
historial[idx].dia, historial[idx].mes, historial[idx].anio,
historial[idx].hora, historial[idx].minuto,
historial[idx].presion, historial[idx].nivel);
datosCSV += filaCSV;
}
config.server.host_name = SMTP_HOST;
config.server.port = SMTP_PORT;
config.login.user_domain = "";
config.login.email = REPOSITORIO_CORREO;
config.login.password = REPOSITORIO_PASS;
config.secure.mode = esp_mail_secure_mode_ssl_tls;
message.sender.name = "Planta de Monitoreo ESP32";
message.sender.email = REPOSITORIO_CORREO;
message.subject = "Reporte Industrial - " + motivo + " (" + obtenerFechaActual() + ")";
message.addRecipient("Operador", CORREO_DESTINO);
textoMensaje = "Saludos.\nSe adjunta el reporte de variables de la planta solicitado.\n";
textoMensaje += "Fecha de emision: " + obtenerFechaActual() + "\n";
textoMensaje += "Registros totales contenidos: " + String(totalRegistros) + "\n";
message.text.content = textoMensaje.c_str();
att.descr.filename = "Reporte_Planta.csv";
att.descr.mime = "text/csv";
att.blob.data = (uint8_t *)datosCSV.c_str();
att.blob.size = datosCSV.length();
message.clearAttachments();
message.addAttachment(att);
if (!smtp.connect(&config)) return false;
return MailClient.sendMail(&smtp, &message);
}
void manejarPeticionCorreo() {
if (!modoOnline) { server.send(403, "text/plain", "MODO_OFFLINE"); return; }
exitoEnvioCorreo = enviarReportePorCorreo("Solicitud Manual Web");
if (exitoEnvioCorreo) server.send(200, "text/plain", "OK");
else server.send(500, "text/plain", "ERROR");
}
void manejarRearmeBomba() {
if (bombaBloqueada && EstadoI1 < Limitedesensores) {
bombaBloqueada = false;
digitalWrite(O6, HIGH);
server.send(200, "text/plain", "REARMADO");
} else {
server.send(400, "text/plain", "BLOQUEADO_POR_CRITICO");
}
}
void manejarRutaRaiz() {
paginaHTML = "<!DOCTYPE html><html lang='es'><head><meta charset='UTF-8'>";
paginaHTML += "<meta name='viewport' content='width=device-width, initial-scale=1'>";
paginaHTML += "<title>Monitoreo de la Planta</title>";
paginaHTML += "<style>";
paginaHTML += ":root { font-family: system-ui, -apple-system, sans-serif; background-color: #f4f6f9; color: #333; }";
paginaHTML += "body { max-width: 850px; margin: 20px auto; padding: 20px; background: #fff; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.05); }";
paginaHTML += "h2 { color: #1a73e8; border-bottom: 2px solid #eee; padding-bottom: 10px; margin-top: 0; font-size: 1.4rem; }";
paginaHTML += "h3 { color: #3c4043; font-size: 1.2rem; }";
paginaHTML += ".card { background: #f8f9fa; padding: 20px; border-radius: 8px; margin-bottom: 20px; border-left: 5px solid #1a73e8; }";
paginaHTML += ".val { font-size: 1.8rem; font-weight: bold; color: #202124; }";
paginaHTML += ".btn { font-size: 16px; padding: 12px 20px; color: white; border: none; cursor: pointer; border-radius: 6px; font-weight: bold; transition: opacity 0.2s; margin-right: 10px; margin-bottom: 10px; display: inline-block; text-align: center; }";
paginaHTML += ".btn:hover { opacity: 0.9; }";
paginaHTML += ".table-container { overflow-x: auto; max-height: 400px; }";
paginaHTML += "table { width: 100%; border-collapse: collapse; margin-top: 10px; font-size: 15px; }";
paginaHTML += "th, td { padding: 10px 12px; border: 1px solid #e0e0e0; text-align: center; }";
paginaHTML += "th { background-color: #f1f3f4; color: #3c4043; position: sticky; top: 0; }";
paginaHTML += "tr:nth-child(even) { background-color: #f8f9fa; }";
paginaHTML += "</style>";
paginaHTML += "<script>";
paginaHTML += "setInterval(function() {";
paginaHTML += " fetch('/datos').then(response => response.json()).then(data => {";
paginaHTML += " document.getElementById('valPresion').innerHTML = data.presion + ' PSI';";
paginaHTML += " document.getElementById('reloj').innerHTML = data.hora;";
paginaHTML += " document.getElementById('fechaActual').innerHTML = data.fecha;";
paginaHTML += " var btnBomba = document.getElementById('btnBomba');";
paginaHTML += " if(data.bombaBloqueada) {";
paginaHTML += " btnBomba.style.backgroundColor = '#f44336'; btnBomba.value = '🛑 Rearmar Bomba (APAGADA POR EMERGENCIA)'; btnBomba.disabled = false;";
paginaHTML += " } else {";
paginaHTML += " btnBomba.style.backgroundColor = '#4CAF50'; btnBomba.value = '⚙️ Bomba Operando Normal'; btnBomba.disabled = true;";
paginaHTML += " }";
paginaHTML += " });";
paginaHTML += "}, 1000);";
paginaHTML += "function rearmarBombaAjax() {";
paginaHTML += " fetch('/rearmar_bomba', { method: 'POST' }).then(response => {";
paginaHTML += " if(!response.ok) alert('No se puede rearmar: ¡El sistema sigue en estado crítico!');";
paginaHTML += " });";
paginaHTML += "}";
paginaHTML += "function enviarCorreoAjax() {";
paginaHTML += " var btn = document.getElementById('btnCorreo');";
paginaHTML += " btn.disabled = true;";
paginaHTML += " btn.style.backgroundColor = '#888888';";
paginaHTML += " btn.value = '⏳ Enviando reporte al correo...';";
paginaHTML += " fetch('/solicitar_correo', { method: 'POST' })";
paginaHTML += " .then(response => {";
paginaHTML += " if(response.ok) {";
paginaHTML += " btn.style.backgroundColor = '#4CAF50';";
paginaHTML += " btn.value = '✅ ¡Reporte enviado con éxito!';";
paginaHTML += " } else {";
paginaHTML += " btn.style.backgroundColor = '#f44336';";
paginaHTML += " btn.value = '❌ Error al enviar';";
paginaHTML += " }";
paginaHTML += " setTimeout(function() {";
paginaHTML += " btn.disabled = false;";
paginaHTML += " btn.style.backgroundColor = '#0078d4';";
paginaHTML += " btn.value = '📧 Enviar Reporte Excel por Gmail';";
paginaHTML += " }, 4000);";
paginaHTML += " }).catch(err => {";
paginaHTML += " btn.disabled = false;";
paginaHTML += " btn.style.backgroundColor = '#0078d4';";
paginaHTML += " btn.value = '📧 Enviar Reporte Excel por Gmail';";
paginaHTML += " });";
paginaHTML += "}";
paginaHTML += "</script>";
paginaHTML += "</head>";
paginaHTML += "<body>";
paginaHTML += "<h2>📊 Monitoreo de la Planta (<span id='fechaActual'>" + obtenerFechaActual() + "</span> - <span id='reloj'>00:00:00</span>)</h2>";
paginaHTML += "<div class='card'>";
paginaHTML += "<strong>Presión Actual:</strong><br><span id='valPresion' class='val'>0.00 PSI</span>";
paginaHTML += "</div>";
paginaHTML += "<div style='margin-bottom: 20px;'>";
paginaHTML += "<input type='button' id='btnBomba' onclick='rearmarBombaAjax()' value='⚙️ Bomba Operando Normal' class='btn' style='background-color:#4CAF50;'><br>";
paginaHTML += "<input type='button' id='btnCorreo' onclick='enviarCorreoAjax()' value='📧 Enviar Reporte Excel por Gmail' class='btn' style='background-color:#0078d4;'>";
paginaHTML += "</div>";
paginaHTML += "<hr><br><h3>📜 Historial de Lecturas (Últimas 24 Horas)</h3>";
paginaHTML += "<div class='table-container'>";
paginaHTML += "<table>";
paginaHTML += "<tr><th>Fecha</th><th>Hora</th><th>Presión (PSI)</th></tr>";
for (idx = totalRegistros - 1; idx >= 0; idx--) {
sprintf(bufferFilaWeb, "%02d:%02d", historial[idx].hora, historial[idx].minuto);
sprintf(bufferFechaWeb, "%02d/%02d/%04d", historial[idx].dia, historial[idx].mes, historial[idx].anio);
paginaHTML += "<tr>";
paginaHTML += "<td>" + String(bufferFechaWeb) + "</td>";
paginaHTML += "<td>" + String(bufferFilaWeb) + "</td>";
paginaHTML += "<td>" + String(historial[idx].presion, 2) + "</td>";
paginaHTML += "</tr>";
}
paginaHTML += "</table></div></body></html>"; // <--- Etiqueta div cerrada correctamente aquí
server.send(200, "text/html", paginaHTML);
}
void enviarDatosJSON() {
jsonResponse = "{";
jsonResponse += "\"presion\":" + String(presionPSI, 2) + ",";
jsonResponse += "\"nivel\":" + String(nivelMetros, 2) + ",";
jsonResponse += "\"hora\":\"" + obtenerHoraActual() + "\",";
jsonResponse += "\"fecha\":\"" + obtenerFechaActual() + "\",";
jsonResponse += "\"bombaBloqueada\":" + String(bombaBloqueada ? "true" : "false");
jsonResponse += "}";
server.send(200, "application/json", jsonResponse);
}
// ============================================================================
// 3. CONFIGURACIÓN INICIAL (SETUP)
// ============================================================================
void setup() {
Serial.begin(115200);
smtp.debug({1});
pinMode(I1, INPUT);
pinMode(I2, INPUT);
pinMode(PIN_PULSADOR_REARME, INPUT_PULLUP);
pinMode(O1, OUTPUT);
pinMode(O2, OUTPUT);
pinMode(O4, OUTPUT);
pinMode(O5, OUTPUT);
pinMode(O6, OUTPUT);
digitalWrite(O6, HIGH);
lcd.begin(16, 2);
lcd.clear();
lcd.setCursor(0, 0);
lcd.print("Cargando...");
WiFi.mode(WIFI_STA);
WiFi.begin(ssid, password);
int intentos = 0;
while (WiFi.status() != WL_CONNECTED && intentos < 30) {
delay(500);
Serial.print(".");
intentos++;
}
lcd.clear();
if (WiFi.status() == WL_CONNECTED) {
modoOnline = true;
digitalWrite(O4, HIGH);
Serial.println("\n[SISTEMA] ¡Wi-Fi Conectado! Modo ONLINE activo.");
configTime(gmtOffset_sec, daylightOffset_sec, horaGoogle);
if (MDNS.begin("control")) {
Serial.println("[RED] Acceso mDNS: http://control.local");
}
server.on("/", manejarRutaRaiz);
server.on("/datos", enviarDatosJSON);
server.on("/solicitar_correo", HTTP_POST, manejarPeticionCorreo);
server.on("/rearmar_bomba", HTTP_POST, manejarRearmeBomba);
server.begin();
} else {
modoOnline = false;
digitalWrite(O4, LOW);
Serial.println("\n[SISTEMA] No se pudo conectar a la red. Iniciando en Modo OFFLINE.");
}
}
// ============================================================================
// 4. BUCLE DE OPERACIÓN CÍCLICO (LOOP)
// ============================================================================
void loop() {
if (modoOnline) {
if (WiFi.status() == WL_CONNECTED) {
digitalWrite(O4, HIGH);
server.handleClient();
} else {
digitalWrite(O4, LOW);
}
}
// --- LECTURA Y ESCALADO DE SEÑALES ---
EstadoI1 = analogRead(I1);
EstadoI2 = analogRead(I2);
presionPSI = ((float)EstadoI1 / 4095.0) * 100.0;
nivelMetros = ((float)EstadoI2 / 4095.0) * 3.0;
// --- IMPRESIÓN PERIÓDICA EN EL MONITOR SERIE CADA 2 SEGUNDOS ---
if (millis() - tiempoUltimoPrint >= 2000) {
tiempoUltimoPrint = millis();
String estadoRed = modoOnline ? (WiFi.status() == WL_CONNECTED ? "ONLINE (Conectado)" : "ONLINE (Red Caída)") : "OFFLINE (Local)";
Serial.printf("📊 [ESTADO] Modo: %s | Presión: %.2f PSI | Nivel: %.2f m | Bomba: %s\n",
estadoRed.c_str(),
presionPSI,
nivelMetros,
bombaBloqueada ? "BLOQUEADA (Apagada)" : "OPERANDO (Normal)");
}
// --- CONTROL TEMPORAL AUTOMÁTICO DE LOGS ---
if (modoOnline) {
if (getLocalTime(&timeinfo)) {
horaActual = timeinfo.tm_hour;
minutoActual = timeinfo.tm_min;
diaActual = timeinfo.tm_mday;
mesActual = timeinfo.tm_mon + 1;
anioActual = timeinfo.tm_year + 1900;
if (minutoActual != ultimoMinutoRegistrado) {
agregarAlHistorial(diaActual, mesActual, anioActual, horaActual, minutoActual, presionPSI, nivelMetros);
ultimoMinutoRegistrado = minutoActual;
Serial.printf("💾 [HISTORIAL] Nuevo registro guardado a las: %02d:%02d\n", horaActual, minutoActual);
}
if (horaActual == 0 && minutoActual == 0) {
if (!reporteEnviadoHoy) {
enviarReportePorCorreo("Envio Automatico de Medianoche");
reporteEnviadoHoy = true;
}
} else {
reporteEnviadoHoy = false;
}
}
}
tiempoParpadeo = millis();
// ============================================================================
// GESTIÓN DE LA PANTALLA LCD (FILAS Y ESPACIADO)
// ============================================================================
// --- FILA 1: ESTADO Y HORA ---
lcd.setCursor(0, 0);
if (modoOnline) {
String lineaOnline = "Online " + obtenerHoraActual(); // Ej: "Online 14:35:02"
while (lineaOnline.length() < 16) {
lineaOnline += " ";
}
lcd.print(lineaOnline);
} else {
lcd.print("Offline ");
}
// --- FILA 2: VALOR DE PRESIÓN O ALARMA DE SOBREPRESIÓN ---
lcd.setCursor(0, 1);
if (EstadoI1 >= Limitedesensores || enEstadoCritico) {
lcd.print("! SOBREPRESION !");
} else {
String partePresion = "Pres: " + String(presionPSI, 1) + " PSI";
while (partePresion.length() < 16) {
partePresion += " ";
}
lcd.print(partePresion.substring(0, 16));
}
// ============================================================================
// MONITOREO DE ALARMAS LED
// ============================================================================
// --- LED 1: PRESIÓN ---
if (EstadoI1 >= Limitedesensores) {
if (tiempoParpadeo - tiempoO1 >= 100) {
estadoO1 = !estadoO1; digitalWrite(O1, estadoO1); tiempoO1 = tiempoParpadeo;
}
} else if (EstadoI1 >= Precausion) {
digitalWrite(O1, HIGH);
} else {
digitalWrite(O1, LOW);
}
// --- LED 2: NIVEL BAJO (SIGUE OPERANDO INTERNAMENTE) ---
if (EstadoI2 <= umbralNivelBajo) {
if (tiempoParpadeo - tiempoO2 >= 100) {
estadoO2 = !estadoO2;
digitalWrite(O2, estadoO2);
tiempoO2 = tiempoParpadeo;
}
} else if (EstadoI2 <= umbralNivelPrecaucion) {
digitalWrite(O2, HIGH);
} else {
digitalWrite(O2, LOW);
}
// ============================================================================
// PROTECCIÓN DE BOMBA Y ALARMA SONORA (DOBLE UMBRAL DE PRESIÓN)
// ============================================================================
// CASO 1: Disparo INMEDIATO (Supera 4000)
if (EstadoI1 >= Limitedesensores) {
if (!bombaBloqueada) {
bombaBloqueada = true;
digitalWrite(O6, LOW);
alarmaSonoraActiva = true;
digitalWrite(O5, HIGH);
Serial.println("🚨 [ALERTA CRÍTICA] ¡DISPARO INMEDIATO! Presión extrema alcanzada. Bomba bloqueada.");
}
}
// CASO 2: Disparo por tiempo de 10 segundos (Supera 3000)
else if (EstadoI1 >= UmbralRetardo) {
if (!enEstadoCritico) {
tiempoInicioCritico = millis();
enEstadoCritico = true;
Serial.println("⚠️ [ADVERTENCIA] Presión elevada detectada. Iniciando conteo de seguridad de 10s...");
}
if (millis() - tiempoInicioCritico >= 10000 && !bombaBloqueada) {
bombaBloqueada = true;
digitalWrite(O6, LOW);
alarmaSonoraActiva = true;
digitalWrite(O5, HIGH);
Serial.println("🚨 [ALERTA CRÍTICA] ¡TIEMPO AGOTADO (10s)! Presión sostenida elevada. Bomba bloqueada.");
}
}
// CASO 3: Normalización (Por debajo del umbral de precaución)
else {
enEstadoCritico = false;
if (EstadoI1 < Precausion && alarmaSonoraActiva) {
alarmaSonoraActiva = false;
digitalWrite(O5, LOW);
Serial.println("✅ [SISTEMA] Presión normalizada. Alarma sonora desactivada.");
}
}
// ============================================================================
// PULSADOR FÍSICO DE REARME (RESTAURADO Y FUNCIONAL)
// ============================================================================
if (digitalRead(PIN_PULSADOR_REARME) == LOW) {
if (millis() - tiempoPulsadorAnterior > 300) { // Antirrebote de 300ms
tiempoPulsadorAnterior = millis();
if (bombaBloqueada) {
if (EstadoI1 < Limitedesensores) {
bombaBloqueada = false;
enEstadoCritico = false;
digitalWrite(O6, HIGH);
digitalWrite(O5, LOW);
alarmaSonoraActiva = false;
Serial.println("⚙️ [ACCIÓN] ¡Bomba rearmada exitosamente mediante el pulsador físico!");
} else {
Serial.println("⚠️ [DENEGADO] La presión sigue en el límite crítico (>=4000). Baja la presión antes de rearmar.");
}
} else {
Serial.println("ℹ️ [INFO] Pulsador presionado, pero la bomba ya está operando normalmente.");
}
}
}
}Loading
esp32-devkit-c-v4
esp32-devkit-c-v4