/*
ESP1_Purificadora_Controller_AllInOne.ino
Purificadora Contactor - Dual ESP32 system
ESP#1 = Maestro (control bombas + 2 sensores AJ-SR04M + portal config + NTP + MQTT + UDP)
Features included (hasta el momento):
- AP local PURIF_CTRL (sin internet) + UDP con ESP#2 (HMI)
- STA opcional (para NTP y/o MQTT) configurable por portal
- Captive Portal (DNS wildcard + WebServer) en 192.168.4.1
- MQTT ready + runtime config (enable/host/port/user/pass) via portal y via CMD=MQTT desde ESP#2
- Control: 2 bombas SIEMPRE juntas, ciclo 10min ON / 10min OFF, solo si hay demanda
- Modos por entradas digitales: CONTINUO o NOCTURNO 20:00-07:00 (requiere hora válida)
- Sensores: T1 y T2 locales; T3 recibido de ESP#2 (crudo). Failsafe: si no hay comm -> bombas OFF
- Umbrales configurables: LOW/HIGH (T1/T2) y RAW_MIN (T3). RAW_REARM = RAW_MIN + 5
- Sesión máxima (duración) configurable: AUTO/ilimitado o X minutos
- Calibración FULL/EMPTY por tanque (T1/T2) via CMD=CAL
- Persistencia NVS: thresholds, duration, calibration, MQTT config, STA creds, contadores
- Diagnóstico (telemetría): RSSI, MQTT fails, MQTT last err, age publish, NTP ok/age, HEAP, reset reason
- Telemetría a ESP#2 por UDP KV + JSON extra J={...} (cimientos MQTT)
*/
// --- Wokwi Simulation Toggle ---
#ifndef WOKWI_SIM_BYPASS_T3
#define WOKWI_SIM_BYPASS_T3 1
#endif
#include <WiFi.h>
#include <WiFiUdp.h>
#include <Preferences.h>
#include <PubSubClient.h>
#include <WebServer.h>
#include <DNSServer.h>
#include <time.h>
#include <esp_system.h>
#include <Wire.h>
#include <LiquidCrystal_I2C.h>
// FSM
enum ProcState { STBY=0, RUN=1, LOCK_RAW=2, LOCK_SENS=3, LOCK_TIME=4 };
enum CycleState { IDLE=0, ON=1, REST=2 };
ProcState procState = STBY;
CycleState cycState = IDLE;
uint32_t cycSinceMs = 0;
enum WhyCode { WHY_OK=0, WHY_RAW_LOW, WHY_NO_COMM, WHY_NO_TIME, WHY_SENS, WHY_NOT_ALLOWED };
enum ModeCode { MODE_OFF=0, MODE_CONT, MODE_NIGHT };
WhyCode whyCode = WHY_NOT_ALLOWED;
ModeCode modeCode = MODE_OFF;
// =====================================================
static const char* AP_SSID = "PURIF_CTRL";
static const char* AP_PASS = "12345678";
static IPAddress AP_IP (192,168,4,1);
static IPAddress AP_GW (192,168,4,1);
static IPAddress AP_MASK(255,255,255,0);
// UDP ports (ESP2 -> ESP1 y ESP1 -> broadcast)
static const uint16_t UDP_RX_FROM_ESP2 = 4210;
static const uint16_t UDP_TX_TO_ESP2 = 4211;
static IPAddress UDP_BCAST(192,168,4,255);
// NTP
static const char* NTP_SERVER1 = "pool.ntp.org";
static const long GMT_OFFSET_SEC = -6 * 3600; // Mexico City (sin DST)
static const int DST_OFFSET_SEC = 0;
// Cycle 10/10
static const uint32_t ON_MS = 10UL * 60UL * 1000UL;
static const uint32_t OFF_MS = 10UL * 60UL * 1000UL;
// ESP2 comm timeout (failsafe)
static const uint32_t ESP2_TIMEOUT_MS = 5000;
// Enable portal
static const bool PORTAL_ENABLED = true;
// =====================================================
// PINS (ESP#1 DevKit)
// =====================================================
// Entradas modo (nota: GPIO34/35 son input-only, sin pullups internos)
static const int PIN_IN_CONTINUO = 34; // HIGH = modo continuo (si usas switch a 3.3V con resistencia externa)
static const int PIN_IN_NOCHE = 35; // HIGH = modo noche (20:00-07:00), requiere hora válida
// AJ-SR04M T1
static const int PIN_T1_TRIG = 18;
static const int PIN_T1_ECHO = 19;
// AJ-SR04M T2
static const int PIN_T2_TRIG = 27;
static const int PIN_T2_ECHO = 4;
// Salidas relé/contactores (bombas juntas)
static const int PIN_OUT_BOMBA1 = 32;
static const int PIN_OUT_BOMBA2 = 33;
// =====================================================
// STATE + CONFIG
// =====================================================
Preferences prefs;
static const char* PREF_NS = "purif";
WiFiUDP udp;
// Portal
WebServer web(80);
DNSServer dns;
static const byte DNS_PORT = 53;
// MQTT
#define USE_MQTT 1
static const char* MQTT_TOPIC_STATUS = "purif/status";
static const char* MQTT_TOPIC_CMD = "purif/cmd";
WiFiClient wifiClient;
PubSubClient mqttClient(wifiClient);
// Runtime config (NVS / Portal / ESP2)
String staSsidStr = "";
String staPassStr = "";
bool mqttEnabled = false;
String mqttHostStr = "192.168.4.2";
uint16_t mqttPort = 1883;
String mqttUserStr = "";
String mqttPassStr = "";
// Time
bool timeValid = false;
uint32_t ntp_last_sync_ms = 0;
// Tanks calibration (distance to water surface)
float T1_DIST_EMPTY_CM = 180.0f, T1_DIST_FULL_CM = 25.0f;
float T2_DIST_EMPTY_CM = 180.0f, T2_DIST_FULL_CM = 25.0f;
// Thresholds
float RAW_MIN_PCT = 20.0f;
float RAW_REARM_PCT = 25.0f; // RAW_MIN + 5
float LOW_PCT = 30.0f;
float HIGH_PCT = 85.0f;
// Session
int sessionMaxMin = 0; // 0=auto/ilimitado
bool sessionActive = false;
uint32_t sessionStartMs = 0;
uint32_t sessionEndMs = 0;
bool demandWasCleared = true;
// T3 from ESP2
float t3_pct = NAN;
bool t3_ok = false;
uint32_t lastEsp2RxMs = 0;
// Local T1/T2
float t1_pct = NAN, t2_pct = NAN;
bool t1_ok = false, t2_ok = false;
float t1_cm_now = NAN, t2_cm_now = NAN;
// Counters + diag
uint32_t bootMs = 0;
uint32_t fc_comm = 0, fc_sens = 0, fc_raw = 0;
bool prev_comm_bad = false, prev_sens_bad = false, prev_raw_bad = false;
// MQTT diag
bool mqttConn = false;
int wifi_rssi = -127;
uint32_t mqtt_fail = 0;
int mqtt_last_err = 0;
uint32_t mqtt_last_pub_ms = 0;
// Reset reason
String rst_reason = "RST";
// =====================================================
// UTILS
// =====================================================
static inline float clampf(float v, float lo, float hi){
if (v < lo) return lo;
if (v > hi) return hi;
return v;
}
void setPumps(bool on) {
digitalWrite(PIN_OUT_BOMBA1, on ? HIGH : LOW);
digitalWrite(PIN_OUT_BOMBA2, on ? HIGH : LOW);
}
const char* procStateStr(ProcState s){
switch(s){
case STBY: return "STBY";
case RUN: return "RUN";
case LOCK_RAW: return "LOCK_RAW";
case LOCK_SENS: return "LOCK_SENS";
case LOCK_TIME: return "LOCK_TIME";
}
return "STBY";
}
const char* cycStateStr(CycleState c){
switch(c){
case IDLE: return "IDLE";
case ON: return "ON";
case REST: return "REST";
}
return "IDLE";
}
const char* whyStr(WhyCode w){
switch(w){
case WHY_OK: return "OK";
case WHY_RAW_LOW: return "RAW_LOW";
case WHY_NO_COMM: return "NO_COMM";
case WHY_NO_TIME: return "NO_TIME";
case WHY_SENS: return "SENS";
case WHY_NOT_ALLOWED: return "NOT_ALLOWED";
}
return "OK";
}
const char* modeStr(ModeCode m){
switch(m){
case MODE_OFF: return "OFF";
case MODE_CONT: return "CONT";
case MODE_NIGHT: return "NIGHT";
}
return "OFF";
}
bool readInputsContinuo() { return digitalRead(PIN_IN_CONTINUO) == HIGH; }
bool readInputsNoche() { return digitalRead(PIN_IN_NOCHE) == HIGH; }
bool commOk() {
#if WOKWI_SIM_BYPASS_T3
return true;
#endif
return (millis() - lastEsp2RxMs <= ESP2_TIMEOUT_MS); }
uint32_t esp2AgeMs() {
if (lastEsp2RxMs == 0) return 999999;
return millis() - lastEsp2RxMs;
}
// =====================================================
// SR04M (AJ-SR04M)
// =====================================================
float readDistanceCmOnce(int trigPin, int echoPin) {
digitalWrite(trigPin, LOW);
delayMicroseconds(2);
digitalWrite(trigPin, HIGH);
delayMicroseconds(10);
digitalWrite(trigPin, LOW);
uint32_t dur = pulseIn(echoPin, HIGH, 30000);
if (dur == 0) return NAN;
return (float)dur / 58.2f;
}
float median5(float a[5]) {
for(int i=0;i<5;i++)
for(int j=i+1;j<5;j++)
if(a[j] < a[i]) { float t=a[i]; a[i]=a[j]; a[j]=t; }
return a[2];
}
bool readTankCmMedian(int trigPin, int echoPin, float &cmOut) {
float vals[5];
int ok = 0;
for(int i=0;i<5;i++){
float cm = readDistanceCmOnce(trigPin, echoPin);
if(!isnan(cm) && cm > 2 && cm < 400) vals[ok++] = cm;
delay(25);
}
if(ok < 3) return false;
while(ok < 5) { vals[ok] = vals[ok-1]; ok++; }
cmOut = median5(vals);
return true;
}
bool cmToPct(float cm, float distEmpty, float distFull, float &pctOut){
float denom = (distEmpty - distFull);
if (fabs(denom) < 1e-3) return false;
float pct = (distEmpty - cm) / denom * 100.0f;
if (pct < 0) pct = 0;
if (pct > 100) pct = 100;
pctOut = pct;
return true;
}
// =====================================================
// THRESHOLDS
// =====================================================
void applyThresholds(float low, float high, float rawMin) {
low = clampf(low, 1, 95);
high = clampf(high, 5, 99);
rawMin = clampf(rawMin, 1, 95);
if (low >= high - 1) low = high - 1;
if (low < 1) low = 1;
LOW_PCT = low;
HIGH_PCT = high;
RAW_MIN_PCT = rawMin;
RAW_REARM_PCT = clampf(RAW_MIN_PCT + 5.0f, 1, 99);
}
// =====================================================
// NVS load/save
// =====================================================
void loadConfig() {
prefs.begin(PREF_NS, true);
LOW_PCT = prefs.getFloat("low", LOW_PCT);
HIGH_PCT = prefs.getFloat("high", HIGH_PCT);
RAW_MIN_PCT = prefs.getFloat("raw", RAW_MIN_PCT);
sessionMaxMin = prefs.getInt("dur", sessionMaxMin);
T1_DIST_EMPTY_CM = prefs.getFloat("t1e", T1_DIST_EMPTY_CM);
T1_DIST_FULL_CM = prefs.getFloat("t1f", T1_DIST_FULL_CM);
T2_DIST_EMPTY_CM = prefs.getFloat("t2e", T2_DIST_EMPTY_CM);
T2_DIST_FULL_CM = prefs.getFloat("t2f", T2_DIST_FULL_CM);
fc_comm = prefs.getUInt("fc_comm", 0);
fc_sens = prefs.getUInt("fc_sens", 0);
fc_raw = prefs.getUInt("fc_raw", 0);
// STA + MQTT config
staSsidStr = prefs.getString("sta_ssid", "");
staPassStr = prefs.getString("sta_pass", "");
mqttEnabled = prefs.getBool("mq_en", false);
mqttHostStr = prefs.getString("mq_host", mqttHostStr.c_str());
mqttPort = (uint16_t)prefs.getUShort("mq_port", mqttPort);
mqttUserStr = prefs.getString("mq_user", "");
mqttPassStr = prefs.getString("mq_pass", "");
// MQTT diag
mqtt_fail = prefs.getUInt("mq_fail", 0);
prefs.end();
applyThresholds(LOW_PCT, HIGH_PCT, RAW_MIN_PCT);
}
void saveConfig() {
prefs.begin(PREF_NS, false);
prefs.putFloat("low", LOW_PCT);
prefs.putFloat("high", HIGH_PCT);
prefs.putFloat("raw", RAW_MIN_PCT);
prefs.putInt("dur", sessionMaxMin);
prefs.putFloat("t1e", T1_DIST_EMPTY_CM);
prefs.putFloat("t1f", T1_DIST_FULL_CM);
prefs.putFloat("t2e", T2_DIST_EMPTY_CM);
prefs.putFloat("t2f", T2_DIST_FULL_CM);
prefs.putUInt("fc_comm", fc_comm);
prefs.putUInt("fc_sens", fc_sens);
prefs.putUInt("fc_raw", fc_raw);
prefs.putString("sta_ssid", staSsidStr);
prefs.putString("sta_pass", staPassStr);
prefs.putBool("mq_en", mqttEnabled);
prefs.putString("mq_host", mqttHostStr);
prefs.putUShort("mq_port", mqttPort);
prefs.putString("mq_user", mqttUserStr);
prefs.putString("mq_pass", mqttPassStr);
prefs.putUInt("mq_fail", mqtt_fail);
prefs.end();
}
// =====================================================
// TIME / SCHEDULE
// =====================================================
bool isInNightWindow() {
struct tm timeinfo;
if (!getLocalTime(&timeinfo, 50)) return false;
int h = timeinfo.tm_hour;
return (h >= 20) || (h < 7);
}
bool scheduleAllowsNow() {
bool cont = readInputsContinuo();
bool night = readInputsNoche();
if (cont) { modeCode = MODE_CONT; return true; }
if (!night) { modeCode = MODE_OFF; return false; }
modeCode = MODE_NIGHT;
if (!timeValid) return false;
return isInNightWindow();
}
// =====================================================
// SESSION LOGIC + DEMAND
// =====================================================
bool demandNow() {
if (!t1_ok || !t2_ok) return false;
return (t1_pct < LOW_PCT) || (t2_pct < LOW_PCT);
}
bool satisfiedNow() {
if (!t1_ok || !t2_ok) return false;
return (t1_pct > HIGH_PCT) && (t2_pct > HIGH_PCT);
}
void ensureSessionLogic(bool allowed, bool demand, bool satisfied) {
uint32_t now = millis();
if (satisfied || !demand) {
sessionActive = false;
demandWasCleared = true;
return;
}
if (!allowed) {
sessionActive = false;
return;
}
if (sessionMaxMin <= 0) {
if (!sessionActive) {
sessionActive = true;
sessionStartMs = now;
sessionEndMs = 0;
demandWasCleared = false;
}
return;
}
if (!sessionActive) {
if (demandWasCleared) {
sessionActive = true;
sessionStartMs = now;
sessionEndMs = now + (uint32_t)sessionMaxMin * 60UL * 1000UL;
demandWasCleared = false;
}
} else {
if (now >= sessionEndMs) sessionActive = false;
}
}
// =====================================================
// RAW LOCK (T3)
// =====================================================
bool rawAllowed() {
#if WOKWI_SIM_BYPASS_T3
return true;
#endif
if (!commOk()) return false;
if (!t3_ok || isnan(t3_pct)) return false;
static bool rawLocked = false;
if (!rawLocked) {
if (t3_pct < RAW_MIN_PCT) rawLocked = true;
} else {
if (t3_pct >= RAW_REARM_PCT) rawLocked = false;
}
return !rawLocked;
}
bool sensorsOk() {
#if WOKWI_SIM_BYPASS_T3
// Bypass T3 requirement in simulation.
#endif
if (!t1_ok || !t2_ok) return false;
if (!commOk()) return false;
if (!t3_ok) return false;
return true;
}
void setWhyFromConditions(bool allowed) {
bool cont = readInputsContinuo();
bool night = readInputsNoche();
if (!allowed) {
if (!cont && night && !timeValid) whyCode = WHY_NO_TIME;
else whyCode = WHY_NOT_ALLOWED;
return;
}
if (!commOk()) { whyCode = WHY_NO_COMM; return; }
if (!t3_ok || isnan(t3_pct) || !t1_ok || !t2_ok) { whyCode = WHY_SENS; return; }
if (!rawAllowed()) { whyCode = WHY_RAW_LOW; return; }
whyCode = WHY_OK;
}
// =====================================================
// FAIL COUNTERS (edges)
// =====================================================
void tickFailCounters(bool comm_bad, bool sens_bad, bool raw_bad) {
bool changed = false;
if (comm_bad && !prev_comm_bad) { fc_comm++; changed = true; }
if (sens_bad && !prev_sens_bad) { fc_sens++; changed = true; }
if (raw_bad && !prev_raw_bad) { fc_raw++; changed = true; }
prev_comm_bad = comm_bad;
prev_sens_bad = sens_bad;
prev_raw_bad = raw_bad;
if (changed) saveConfig();
}
// =====================================================
// COMMANDS (UDP + MQTT)
// =====================================================
void applyCmdKV(const String &pkt) {
// CMD=DUR;MIN=120;
if (pkt.indexOf("CMD=DUR") >= 0) {
int minPos = pkt.indexOf("MIN=");
if (minPos >= 0) {
int end = pkt.indexOf(';', minPos);
if (end < 0) end = pkt.length();
int m = pkt.substring(minPos + 4, end).toInt();
if (m < 0) m = 0;
sessionMaxMin = m;
saveConfig();
}
return;
}
// CMD=THR;LOW=30;HIGH=85;RAW=20;
if (pkt.indexOf("CMD=THR") >= 0) {
float nLOW = LOW_PCT, nHIGH = HIGH_PCT, nRAW = RAW_MIN_PCT;
int lowPos = pkt.indexOf("LOW=");
if (lowPos >= 0) { int end=pkt.indexOf(';',lowPos); if(end<0) end=pkt.length(); nLOW = pkt.substring(lowPos+4,end).toFloat(); }
int hiPos = pkt.indexOf("HIGH=");
if (hiPos >= 0) { int end=pkt.indexOf(';',hiPos); if(end<0) end=pkt.length(); nHIGH = pkt.substring(hiPos+5,end).toFloat(); }
int rawPos = pkt.indexOf("RAW=");
if (rawPos >= 0) { int end=pkt.indexOf(';',rawPos); if(end<0) end=pkt.length(); nRAW = pkt.substring(rawPos+4,end).toFloat(); }
applyThresholds(nLOW, nHIGH, nRAW);
saveConfig();
return;
}
// CMD=CAL;T=1;ACT=FULL; or ACT=EMPTY; (VIEW is ignored)
if (pkt.indexOf("CMD=CAL") >= 0) {
int tPos = pkt.indexOf("T=");
int aPos = pkt.indexOf("ACT=");
int tank = 0;
String act;
if (tPos >= 0) { int end=pkt.indexOf(';',tPos); if(end<0) end=pkt.length(); tank = pkt.substring(tPos+2,end).toInt(); }
if (aPos >= 0) { int end=pkt.indexOf(';',aPos); if(end<0) end=pkt.length(); act = pkt.substring(aPos+4,end); act.trim(); }
if (act == "VIEW") return;
float cm = NAN;
bool ok = false;
if (tank == 1) ok = readTankCmMedian(PIN_T1_TRIG, PIN_T1_ECHO, cm);
else if (tank == 2) ok = readTankCmMedian(PIN_T2_TRIG, PIN_T2_ECHO, cm);
if (ok) {
if (tank == 1) t1_cm_now = cm;
if (tank == 2) t2_cm_now = cm;
if (act == "FULL") {
if (tank == 1) T1_DIST_FULL_CM = cm;
if (tank == 2) T2_DIST_FULL_CM = cm;
saveConfig();
} else if (act == "EMPTY") {
if (tank == 1) T1_DIST_EMPTY_CM = cm;
if (tank == 2) T2_DIST_EMPTY_CM = cm;
saveConfig();
}
}
return;
}
// CMD=RSTFAIL;
if (pkt.indexOf("CMD=RSTFAIL") >= 0) {
fc_comm = fc_sens = fc_raw = 0;
saveConfig();
return;
}
// CMD=RSTMQ; (reset mqtt fail counter)
if (pkt.indexOf("CMD=RSTMQ") >= 0) {
mqtt_fail = 0;
saveConfig();
return;
}
// CMD=RST; (restart esp1)
if (pkt.indexOf("CMD=RST;") >= 0 || pkt == "CMD=RST") {
delay(200);
ESP.restart();
return;
}
// CMD=NTP; (force NTP sync attempt)
if (pkt.indexOf("CMD=NTP") >= 0) {
if (WiFi.status() == WL_CONNECTED) {
configTime(GMT_OFFSET_SEC, DST_OFFSET_SEC, NTP_SERVER1);
struct tm ti;
if (getLocalTime(&ti, 2500)) { timeValid = true; ntp_last_sync_ms = millis(); }
}
return;
}
// CMD=STARETRY; (force STA retry)
if (pkt.indexOf("CMD=STARETRY") >= 0) {
if (staSsidStr.length()) {
WiFi.mode(WIFI_AP_STA);
WiFi.begin(staSsidStr.c_str(), staPassStr.c_str());
}
return;
}
// CMD=MQTT;EN=1;HOST=...;PORT=...;USER=...;PASS=...;
if (pkt.indexOf("CMD=MQTT") >= 0) {
int enPos = pkt.indexOf("EN=");
int hPos = pkt.indexOf("HOST=");
int pPos = pkt.indexOf("PORT=");
int uPos = pkt.indexOf("USER=");
int pwPos = pkt.indexOf("PASS=");
if (enPos >= 0) { int end=pkt.indexOf(';',enPos); if(end<0) end=pkt.length(); mqttEnabled = (pkt.substring(enPos + 3, end).toInt() == 1); }
if (hPos >= 0) { int end=pkt.indexOf(';',hPos ); if(end<0) end=pkt.length(); mqttHostStr = pkt.substring(hPos + 5, end); mqttHostStr.trim(); }
if (pPos >= 0) { int end=pkt.indexOf(';',pPos ); if(end<0) end=pkt.length(); int pp=pkt.substring(pPos + 5, end).toInt(); if(pp>0 && pp<65536) mqttPort = (uint16_t)pp; }
if (uPos >= 0) { int end=pkt.indexOf(';',uPos ); if(end<0) end=pkt.length(); mqttUserStr = pkt.substring(uPos + 5, end); mqttUserStr.trim(); }
if (pwPos >= 0) { int end=pkt.indexOf(';',pwPos); if(end<0) end=pkt.length(); mqttPassStr = pkt.substring(pwPos + 5, end); mqttPassStr.trim(); }
saveConfig();
#if USE_MQTT
mqttClient.disconnect();
mqttClient.setServer(mqttHostStr.c_str(), mqttPort);
#endif
return;
}
}
// MQTT cmd parsing supports KV like UDP, or minimal JSON { "cmd":"thr", ... }
static String extractJsonStr(const String& s, const char* key) {
String k = String("\"") + key + "\":\"";
int p = s.indexOf(k);
if (p < 0) return "";
p += (int)k.length();
int e = s.indexOf("\"", p);
if (e < 0) return "";
return s.substring(p, e);
}
static bool extractJsonInt(const String& s, const char* key, int &out) {
String k = String("\"") + key + "\":";
int p = s.indexOf(k);
if (p < 0) return false;
p += (int)k.length();
int e = p;
while (e < (int)s.length() && (isDigit(s[e]) || s[e]=='-' )) e++;
out = s.substring(p, e).toInt();
return true;
}
void applyCmdFromJson(const String &js) {
String cmd = extractJsonStr(js, "cmd");
cmd.toLowerCase();
if (cmd == "dur") {
int m=0; if (!extractJsonInt(js, "min", m)) return;
if (m < 0) m = 0;
sessionMaxMin = m;
saveConfig();
return;
}
if (cmd == "thr") {
int low=0, high=0, raw=0;
if (!extractJsonInt(js, "low", low)) return;
if (!extractJsonInt(js, "high", high)) return;
if (!extractJsonInt(js, "raw", raw)) return;
applyThresholds((float)low, (float)high, (float)raw);
saveConfig();
return;
}
if (cmd == "rstfail") { fc_comm = fc_sens = fc_raw = 0; saveConfig(); return; }
if (cmd == "rstmq") { mqtt_fail = 0; saveConfig(); return; }
if (cmd == "rst") { ESP.restart(); return; }
if (cmd == "ntp") { applyCmdKV("CMD=NTP;"); return; }
if (cmd == "staretry"){ applyCmdKV("CMD=STARETRY;"); return; }
}
// MQTT callback
void mqttCallback(char* topic, byte* payload, unsigned int length) {
String msg;
msg.reserve(length + 4);
for (unsigned int i=0; i<length; i++) msg += (char)payload[i];
msg.trim();
if (msg.indexOf("CMD=") >= 0) { applyCmdKV(msg); return; }
if (msg.startsWith("{") && msg.endsWith("}")) { applyCmdFromJson(msg); return; }
}
// MQTT setup/loop
void mqttSetup() {
#if USE_MQTT
mqttClient.setServer(mqttHostStr.c_str(), mqttPort);
mqttClient.setCallback(mqttCallback);
#endif
}
void mqttLoopTryReconnect() {
#if USE_MQTT
if (!mqttEnabled) { mqttConn = false; return; }
if (WiFi.status() != WL_CONNECTED) { mqttConn = false; return; }
mqttConn = mqttClient.connected();
mqtt_last_err = mqttClient.state();
if (!mqttClient.connected()) {
static uint32_t lastTry = 0;
if (millis() - lastTry < 3000) return;
lastTry = millis();
String clientId = "purif-esp1-" + String((uint32_t)ESP.getEfuseMac(), HEX);
bool ok;
if (mqttUserStr.length() > 0) ok = mqttClient.connect(clientId.c_str(), mqttUserStr.c_str(), mqttPassStr.c_str());
else ok = mqttClient.connect(clientId.c_str());
if (ok) {
mqttClient.subscribe(MQTT_TOPIC_CMD);
mqttClient.publish(MQTT_TOPIC_STATUS, "{\"online\":1}", false);
mqttConn = true;
mqtt_last_err = 0;
} else {
mqttConn = false;
mqtt_last_err = mqttClient.state();
mqtt_fail++;
saveConfig();
}
} else {
mqttClient.loop();
mqttConn = mqttClient.connected();
mqtt_last_err = mqttClient.state();
}
#endif
}
// =====================================================
// WIFI STA CONNECT + NTP
// =====================================================
bool tryConnectSTA(uint32_t timeoutMs=12000) {
if (staSsidStr.length() == 0) return false;
WiFi.begin(staSsidStr.c_str(), staPassStr.c_str());
uint32_t t0 = millis();
while (WiFi.status() != WL_CONNECTED && millis() - t0 < timeoutMs) delay(250);
return WiFi.status() == WL_CONNECTED;
}
void setupNTPIfPossible() {
if (WiFi.status() != WL_CONNECTED) return;
configTime(GMT_OFFSET_SEC, DST_OFFSET_SEC, NTP_SERVER1);
struct tm ti;
if (getLocalTime(&ti, 3000)) {
timeValid = true;
ntp_last_sync_ms = millis();
}
}
// =====================================================
// UDP HANDLERS
// =====================================================
void handlePacketKV(const String &pkt) {
// Commands from ESP2
if (pkt.indexOf("CMD=") >= 0) {
applyCmdKV(pkt);
return;
}
// Telemetry from ESP2: T3=..;OK=..;SEQ=..;
int i = 0;
float newT3 = NAN;
bool newOk = false;
while (i < (int)pkt.length()) {
int eq = pkt.indexOf('=', i);
if (eq < 0) break;
int sc = pkt.indexOf(';', eq);
if (sc < 0) sc = pkt.length();
String k = pkt.substring(i, eq); k.trim();
String v = pkt.substring(eq+1, sc); v.trim();
if (k == "T3") newT3 = v.toFloat();
else if (k == "OK") newOk = (v.toInt() == 1);
i = sc + 1;
}
if (!isnan(newT3) && newOk) { t3_pct = newT3; t3_ok = true; }
else { t3_ok = false; }
lastEsp2RxMs = millis();
}
// =====================================================
// STATUS JSON (MQTT-ready)
// =====================================================
String buildStatusJson(uint32_t nowMs, int remSec, int sessRemSec) {
uint32_t upS = (nowMs - bootMs) / 1000UL;
uint32_t mqAgeS = (mqtt_last_pub_ms == 0) ? 999999 : (millis() - mqtt_last_pub_ms) / 1000UL;
uint32_t ntpAgeS = (ntp_last_sync_ms == 0) ? 999999 : (millis() - ntp_last_sync_ms) / 1000UL;
String j;
j.reserve(520);
j += "{";
j += "\"hb\":1";
j += ",\"up\":" + String(upS);
j += ",\"esp2ok\":" + String(commOk()?1:0);
j += ",\"esp2age\":" + String(esp2AgeMs());
j += ",\"t1\":" + String(isnan(t1_pct)?-1.0f:t1_pct,1);
j += ",\"t2\":" + String(isnan(t2_pct)?-1.0f:t2_pct,1);
j += ",\"t3\":" + String(isnan(t3_pct)?-1.0f:t3_pct,1);
j += ",\"low\":" + String((int)roundf(LOW_PCT));
j += ",\"high\":" + String((int)roundf(HIGH_PCT));
j += ",\"raw\":" + String((int)roundf(RAW_MIN_PCT));
j += ",\"mode\":\"" + String(modeStr(modeCode)) + "\"";
j += ",\"why\":\"" + String(whyStr(whyCode)) + "\"";
j += ",\"ps\":\"" + String(procStateStr(procState)) + "\"";
j += ",\"cyc\":\"" + String(cycStateStr(cycState)) + "\"";
j += ",\"rem\":" + String(remSec);
j += ",\"srem\":" + String(sessRemSec);
j += ",\"fc\":{";
j += "\"comm\":" + String(fc_comm);
j += ",\"sens\":" + String(fc_sens);
j += ",\"raw\":" + String(fc_raw);
j += "}";
j += ",\"diag\":{";
j += "\"sta\":" + String(WiFi.status()==WL_CONNECTED?1:0);
j += ",\"rssi\":" + String(wifi_rssi);
j += ",\"mqen\":" + String(mqttEnabled?1:0);
j += ",\"mqconn\":" + String(mqttConn?1:0);
j += ",\"mqf\":" + String(mqtt_fail);
j += ",\"mqerr\":" + String(mqtt_last_err);
j += ",\"mqage\":" + String(mqAgeS);
j += ",\"ntp\":" + String(timeValid?1:0);
j += ",\"ntpage\":" + String(ntpAgeS);
j += "}";
j += "}";
return j;
}
// =====================================================
// UDP SEND STATUS to ESP2 (KV + JSON + DIAG)
// =====================================================
void udpSendStatusToEsp2() {
uint32_t now = millis();
int remSec = -1;
if (procState == RUN) {
if (cycState == ON) {
uint32_t e = now - cycSinceMs;
remSec = (e >= ON_MS) ? 0 : (int)((ON_MS - e) / 1000);
} else if (cycState == REST) {
uint32_t e = now - cycSinceMs;
remSec = (e >= OFF_MS) ? 0 : (int)((OFF_MS - e) / 1000);
}
}
int sessRemSec = -1;
if (sessionActive && sessionMaxMin > 0) {
sessRemSec = (sessionEndMs > now) ? (int)((sessionEndMs - now) / 1000) : 0;
}
uint32_t upS = (now - bootMs) / 1000UL;
// DIAG calcs
int staOn = (WiFi.status() == WL_CONNECTED) ? 1 : 0;
IPAddress staIp = staOn ? WiFi.localIP() : IPAddress(0,0,0,0);
wifi_rssi = staOn ? WiFi.RSSI() : -127;
uint32_t mqAgeS = (mqtt_last_pub_ms == 0) ? 999999 : (millis() - mqtt_last_pub_ms) / 1000UL;
uint32_t ntpAgeS = (ntp_last_sync_ms == 0) ? 999999 : (millis() - ntp_last_sync_ms) / 1000UL;
uint32_t heap = ESP.getFreeHeap();
String msg;
msg.reserve(1200);
// Basic
msg += "HB=1;";
msg += "UP=" + String(upS) + ";";
msg += "ESP2OK=" + String(commOk()?1:0) + ";";
msg += "ESP2AGE=" + String(esp2AgeMs()) + ";";
// Fail counters
msg += "FC_COMM=" + String(fc_comm) + ";";
msg += "FC_SENS=" + String(fc_sens) + ";";
msg += "FC_RAW=" + String(fc_raw) + ";";
// Tank levels
msg += "T1=" + String(isnan(t1_pct) ? -1.0f : t1_pct, 1) + ";";
msg += "T2=" + String(isnan(t2_pct) ? -1.0f : t2_pct, 1) + ";";
msg += "T3=" + String(isnan(t3_pct) ? -1.0f : t3_pct, 1) + ";";
// Control status
msg += "MODE=" + String(modeStr(modeCode)) + ";";
msg += "WHY=" + String(whyStr(whyCode)) + ";";
msg += "STATE=" + String(procStateStr(procState)) + ";";
msg += "CYC=" + String(cycStateStr(cycState)) + ";";
msg += "REM=" + String(remSec) + ";";
msg += "SESSREM=" + String(sessRemSec) + ";";
msg += "DURMIN=" + String(sessionMaxMin) + ";";
msg += "TIMEOK=" + String(timeValid?1:0) + ";";
// Thresholds
msg += "LOW=" + String(LOW_PCT, 1) + ";";
msg += "HIGH=" + String(HIGH_PCT, 1) + ";";
msg += "RAW=" + String(RAW_MIN_PCT, 1) + ";";
// Calibration visibility
msg += "T1CM=" + String(isnan(t1_cm_now) ? -1.0f : t1_cm_now, 1) + ";";
msg += "T2CM=" + String(isnan(t2_cm_now) ? -1.0f : t2_cm_now, 1) + ";";
msg += "T1E=" + String(T1_DIST_EMPTY_CM, 1) + ";";
msg += "T1F=" + String(T1_DIST_FULL_CM, 1) + ";";
msg += "T2E=" + String(T2_DIST_EMPTY_CM, 1) + ";";
msg += "T2F=" + String(T2_DIST_FULL_CM, 1) + ";";
// STA/MQTT basic
msg += "STA=" + String(staOn) + ";";
msg += "STAIP=" + staIp.toString() + ";";
msg += "MQEN=" + String(mqttEnabled ? 1 : 0) + ";";
msg += "MQCONN=" + String(mqttConn ? 1 : 0) + ";";
msg += "MQHOST=" + mqttHostStr + ";";
msg += "MQPORT=" + String(mqttPort) + ";";
// DIAG extra
msg += "RSSI=" + String(wifi_rssi) + ";";
msg += "MQF=" + String(mqtt_fail) + ";";
msg += "MQERR=" + String(mqtt_last_err) + ";";
msg += "MQAGE=" + String(mqAgeS) + ";";
msg += "NTP=" + String(timeValid ? 1 : 0) + ";";
msg += "NTPAGE=" + String(ntpAgeS) + ";";
msg += "HEAP=" + String(heap) + ";";
msg += "RST=" + rst_reason + ";";
// JSON extra
String js = buildStatusJson(now, remSec, sessRemSec);
msg += "J=" + js + ";";
// Send broadcast
udp.beginPacket(UDP_BCAST, UDP_TX_TO_ESP2);
udp.write((const uint8_t*)msg.c_str(), msg.length());
udp.endPacket();
#if USE_MQTT
if (mqttClient.connected()) {
bool okp = mqttClient.publish(MQTT_TOPIC_STATUS, js.c_str(), false);
if (okp) mqtt_last_pub_ms = millis();
else { mqtt_fail++; saveConfig(); }
}
#endif
}
// =====================================================
// CONTROL FSM
// =====================================================
void processFSM() {
uint32_t now = millis();
bool allowed = scheduleAllowsNow();
// Read sensors periodically
static uint32_t lastSense = 0;
if (now - lastSense > 1200) {
lastSense = now;
float cm1, cm2;
bool ok1 = readTankCmMedian(PIN_T1_TRIG, PIN_T1_ECHO, cm1);
bool ok2 = readTankCmMedian(PIN_T2_TRIG, PIN_T2_ECHO, cm2);
t1_ok = ok1;
t2_ok = ok2;
if (ok1) { t1_cm_now = cm1; cmToPct(cm1, T1_DIST_EMPTY_CM, T1_DIST_FULL_CM, t1_pct); }
if (ok2) { t2_cm_now = cm2; cmToPct(cm2, T2_DIST_EMPTY_CM, T2_DIST_FULL_CM, t2_pct); }
}
bool dem = demandNow();
bool sat = satisfiedNow();
ensureSessionLogic(allowed, dem, sat);
bool runRequest = sessionActive && dem && !sat;
setWhyFromConditions(allowed);
// Fail counters (edge-based)
bool comm_bad = allowed && !commOk();
bool sens_bad = allowed && (!t1_ok || !t2_ok || !t3_ok);
bool raw_bad = allowed && commOk() && (WOKWI_SIM_BYPASS_T3 ? true : t3_ok) && !rawAllowed();
tickFailCounters(comm_bad, sens_bad, raw_bad);
// Priority stops
if (!allowed) {
procState = STBY; cycState = IDLE; setPumps(false); return;
}
if (!commOk() || !sensorsOk()) {
procState = LOCK_SENS; cycState = IDLE; setPumps(false); return;
}
if (!rawAllowed()) {
procState = LOCK_RAW; cycState = IDLE; setPumps(false); return;
}
if (!runRequest) {
procState = STBY; cycState = IDLE; setPumps(false); return;
}
procState = RUN;
// 10/10 cycle
if (cycState == IDLE) {
cycState = ON;
cycSinceMs = now;
setPumps(true);
return;
}
if (cycState == ON) {
if (now - cycSinceMs >= ON_MS) {
cycState = REST;
cycSinceMs = now;
setPumps(false);
} else setPumps(true);
return;
}
if (cycState == REST) {
if (now - cycSinceMs >= OFF_MS) {
cycState = ON;
cycSinceMs = now;
setPumps(true);
} else setPumps(false);
return;
}
}
// =====================================================
// CAPTIVE PORTAL
// =====================================================
static String htmlEscape(const String &s){
String o = s;
o.replace("&","&"); o.replace("<","<"); o.replace(">",">");
o.replace("\"","""); o.replace("'","'");
return o;
}
static String portalPage(const String &msg="") {
String ssid = htmlEscape(staSsidStr);
String mhost = htmlEscape(mqttHostStr);
String muser = htmlEscape(mqttUserStr);
String mpass = htmlEscape(mqttPassStr);
String h;
h.reserve(2600);
h += "<!doctype html><html><head><meta charset='utf-8'>";
h += "<meta name='viewport' content='width=device-width,initial-scale=1'>";
h += "<title>PURIF Config</title>";
h += "<style>body{font-family:Arial;margin:16px}input,select{width:100%;padding:10px;margin:6px 0}";
h += "button{padding:12px 14px;margin:6px 0;width:100%} .ok{color:green} .err{color:red}";
h += ".box{max-width:520px;margin:auto;border:1px solid #ddd;border-radius:12px;padding:16px}</style>";
h += "</head><body><div class='box'>";
h += "<h2>PURIF ESP#1 Config</h2>";
if (msg.length()) h += "<p>" + msg + "</p>";
h += "<form method='POST' action='/save'>";
h += "<h3>WiFi STA</h3>";
h += "<label>SSID</label><input name='sta_ssid' value='" + ssid + "'>";
h += "<label>Password</label><input name='sta_pass' type='password' value='" + htmlEscape(staPassStr) + "'>";
h += "<h3>MQTT</h3>";
h += "<label>Enable</label><select name='mq_en'>";
h += String("<option value='0'") + (mqttEnabled? "" : " selected") + ">OFF</option>";
h += String("<option value='1'") + (mqttEnabled? " selected" : "") + ">ON</option>";
h += "</select>";
h += "<label>Broker Host/IP</label><input name='mq_host' value='" + mhost + "'>";
h += "<label>Port</label><input name='mq_port' value='" + String(mqttPort) + "'>";
h += "<label>User (opcional)</label><input name='mq_user' value='" + muser + "'>";
h += "<label>Pass (opcional)</label><input name='mq_pass' type='password' value='" + mpass + "'>";
h += "<button type='submit'>Guardar y Reiniciar</button>";
h += "</form>";
h += "<form method='POST' action='/test'>";
h += "<button type='submit'>Probar STA ahora (sin reiniciar)</button>";
h += "</form>";
h += "<p><b>AP:</b> " + String(AP_SSID) + " | <b>IP:</b> 192.168.4.1</p>";
h += "</div></body></html>";
return h;
}
void portalHandleRoot() {
web.send(200, "text/html", portalPage(""));
}
void portalHandleSave() {
staSsidStr = web.arg("sta_ssid");
staPassStr = web.arg("sta_pass");
mqttEnabled = (web.arg("mq_en").toInt() == 1);
mqttHostStr = web.arg("mq_host");
mqttPort = (uint16_t)web.arg("mq_port").toInt();
mqttUserStr = web.arg("mq_user");
mqttPassStr = web.arg("mq_pass");
staSsidStr.trim(); staPassStr.trim();
mqttHostStr.trim(); mqttUserStr.trim(); mqttPassStr.trim();
if (mqttPort == 0) mqttPort = 1883;
saveConfig();
web.send(200, "text/html", portalPage("<span class='ok'>Guardado. Reiniciando...</span>"));
delay(800);
ESP.restart();
}
void portalHandleTest() {
WiFi.mode(WIFI_AP_STA);
bool ok = tryConnectSTA(12000);
if (ok) {
setupNTPIfPossible();
mqttClient.disconnect();
mqttClient.setServer(mqttHostStr.c_str(), mqttPort);
web.send(200, "text/html", portalPage("<span class='ok'>STA OK: " + WiFi.localIP().toString() + "</span>"));
} else {
web.send(200, "text/html", portalPage("<span class='err'>STA FAIL. Revisa SSID/Pass.</span>"));
}
}
void portalHandleNotFound() {
web.sendHeader("Location", "http://192.168.4.1/", true);
web.send(302, "text/plain", "");
}
void portalSetup() {
if (!PORTAL_ENABLED) return;
dns.start(DNS_PORT, "*", AP_IP);
web.on("/", HTTP_GET, portalHandleRoot);
web.on("/save", HTTP_POST, portalHandleSave);
web.on("/test", HTTP_POST, portalHandleTest);
web.onNotFound(portalHandleNotFound);
web.begin();
}
// =====================================================
// SETUP / LOOP
// =====================================================
// WOKWI SIM: LCD 20x4 + local T3 (3rd SR04) helper
// - LCD I2C: addr 0x27, SDA=21 SCL=22
// - Local T3 SR04: TRIG=15 ECHO=16 (only for simulation)
// - Keeps commOk() true by refreshing lastEsp2RxMs
// =====================================================
static LiquidCrystal_I2C simLcd(0x27, 20, 4);
static uint32_t simLcdLastMs = 0;
static void simLcdLine(uint8_t row, const String &s) {
simLcd.setCursor(0, row);
String out = s;
if (out.length() > 20) out = out.substring(0, 20);
while (out.length() < 20) out += " ";
simLcd.print(out);
}
static String simOnOff(bool v){ return v ? "ON" : "OFF"; }
static String simPct(float v){
if (isnan(v)) return "--";
int iv = (int)lroundf(v);
if (iv < 0) iv = 0; if (iv > 100) iv = 100;
return String(iv);
}
// Local T3 SR04 pins for Wokwi
#ifndef SIM_T3_TRIG
#define SIM_T3_TRIG 15
#endif
#ifndef SIM_T3_ECHO
#define SIM_T3_ECHO 16
#endif
#ifndef SIM_T3_DIST_EMPTY_CM
#define SIM_T3_DIST_EMPTY_CM 400.0f
#endif
#ifndef SIM_T3_DIST_FULL_CM
#define SIM_T3_DIST_FULL_CM 20.0f
#endif
static float simReadDistanceCmOnce(int trigPin, int echoPin) {
digitalWrite(trigPin, LOW);
delayMicroseconds(2);
digitalWrite(trigPin, HIGH);
delayMicroseconds(10);
digitalWrite(trigPin, LOW);
uint32_t dur = pulseIn(echoPin, HIGH, 30000);
if (dur == 0) return NAN;
return (float)dur / 58.2f;
}
static bool simUpdateLocalT3() {
// If T3 sensor is not responding in Wokwi, allow bypass so the pumps can be simulated.
#if WOKWI_SIM_BYPASS_T3
// Keep comm alive so commOk() won't lock the FSM.
lastEsp2RxMs = millis();
#endif
float cm = simReadDistanceCmOnce(SIM_T3_TRIG, SIM_T3_ECHO);
if (isnan(cm) || cm < 2 || cm > 400) {
#if WOKWI_SIM_BYPASS_T3
// Bypass: pretend OK and keep a mid-level value so RAW lock won't engage.
t3_ok = true;
if (t3_pct < 0 || t3_pct > 100) t3_pct = 60;
return true;
#else
t3_ok = false;
return false;
#endif
}
float pct = 0.0f;
// Reuse your cmToPct() helper if available
cmToPct(cm, SIM_T3_DIST_EMPTY_CM, SIM_T3_DIST_FULL_CM, pct);
t3_pct = pct;
t3_ok = true;
// Keep "comm ok" alive in simulation
lastEsp2RxMs = millis();
return true;
}
static void simLcdInit() {
simLcd.init();
simLcd.backlight();
simLcd.clear();
simLcdLine(0, "ESP1 WOKWI SIM");
}
static void simLcdTick() {
if (millis() - simLcdLastMs < 500) return;
simLcdLastMs = millis();
// Update T3 local each tick
simUpdateLocalT3();
// Read pump outputs (LEDs)
bool b1 = (digitalRead(PIN_OUT_BOMBA1) == HIGH);
bool b2 = (digitalRead(PIN_OUT_BOMBA2) == HIGH);
// Lines:
// L1: T1/T2
simLcdLine(0, "T1:" + simPct(t1_pct) + "% T2:" + simPct(t2_pct) + "%");
// L2: T3 + B1
simLcdLine(1, "T3:" + simPct(t3_pct) + "% B1:" + simOnOff(b1));
// L3: B2 + MODE
simLcdLine(2, "B2:" + simOnOff(b2) + " M:" + String(modeStr(modeCode)));
// L4: WHY + STATE
simLcdLine(3, "WHY:" + String(whyStr(whyCode)) + " S:" + String(procStateStr(procState)));
}
// =====================================================
// USER TUNABLES
// =====================================================
// AP local para operación sin internet
void setup() {
// --- Wokwi SIM LCD init ---
simLcdInit();
pinMode(SIM_T3_TRIG, OUTPUT);
pinMode(SIM_T3_ECHO, INPUT);
Serial.begin(115200);
bootMs = millis();
// Reset reason
esp_reset_reason_t rr = esp_reset_reason();
switch(rr){
case ESP_RST_POWERON: rst_reason="PWRON"; break;
case ESP_RST_SW: rst_reason="SW"; break;
case ESP_RST_PANIC: rst_reason="PANIC"; break;
case ESP_RST_INT_WDT: rst_reason="WDT"; break;
case ESP_RST_TASK_WDT: rst_reason="TWDT"; break;
case ESP_RST_BROWNOUT: rst_reason="BROWN"; break;
default: rst_reason="RST"; break;
}
pinMode(PIN_IN_CONTINUO, INPUT);
pinMode(PIN_IN_NOCHE, INPUT);
pinMode(PIN_T1_TRIG, OUTPUT); digitalWrite(PIN_T1_TRIG, LOW);
pinMode(PIN_T1_ECHO, INPUT);
pinMode(PIN_T2_TRIG, OUTPUT); digitalWrite(PIN_T2_TRIG, LOW);
pinMode(PIN_T2_ECHO, INPUT);
pinMode(PIN_OUT_BOMBA1, OUTPUT);
pinMode(PIN_OUT_BOMBA2, OUTPUT);
setPumps(false);
loadConfig();
// AP always
WiFi.mode(WIFI_AP);
WiFi.softAPConfig(AP_IP, AP_GW, AP_MASK);
WiFi.softAP(AP_SSID, AP_PASS);
// UDP
udp.begin(UDP_RX_FROM_ESP2);
// Portal
portalSetup();
// Optional STA auto connect if configured
if (staSsidStr.length()) {
WiFi.mode(WIFI_AP_STA);
tryConnectSTA(8000);
setupNTPIfPossible();
}
mqttSetup();
}
void loop() {
// Captive portal
if (PORTAL_ENABLED) {
dns.processNextRequest();
web.handleClient();
}
// UDP RX
int len = udp.parsePacket();
if (len > 0) {
String pkt; pkt.reserve(600);
while (len--) pkt += (char)udp.read();
handlePacketKV(pkt);
}
// NTP retry if enabled and STA connected but time invalid
static uint32_t lastNtpTry = 0;
if (!timeValid && staSsidStr.length() && WiFi.status() == WL_CONNECTED && millis() - lastNtpTry > 30000) {
lastNtpTry = millis();
setupNTPIfPossible();
}
// MQTT loop
mqttLoopTryReconnect();
// Control process
processFSM();
// Periodic status to ESP2 (+ MQTT publish if connected)
static uint32_t lastTx = 0;
if (millis() - lastTx > 1000) {
lastTx = millis();
udpSendStatusToEsp2();
}
// --- Wokwi SIM LCD tick ---
simLcdTick();
}