/**
* PROJECT: OEM-Grade Pump Controller V4.5 – Production Freeze Version
* CHANGELOG v4.5 (Production Freeze):
* - Fixed bug logging durasi run (sekarang pakai motorTimer yang benar)
* - Removed redundant condition in stop logic
* - Added 3-second power-on delay before allowing first pump start
* - Added boot reason logging (brownout, watchdog, power-on, etc.)
* - Minor: Logging lebih ringkas & jelas untuk teknisi lapangan
* - No new major features – fokus stabilitas & debuggability
*
* Target: Jet pump, dual tank (toren atas + sumur bawah), float NO, Indonesia field conditions
*/
#include <Arduino.h>
#include <Preferences.h>
#include <esp_task_wdt.h>
// ==========================================
// KONFIGURASI & KONSTANTA
// ==========================================
#define ACTIVE_HIGH_LOGIC // pull-up → LOW = kontak tutup = aktif
#ifdef ACTIVE_HIGH_LOGIC
#define INPUT_ACTIVE(x) (!(x))
#else
#define INPUT_ACTIVE(x) (x)
#endif
// Pin
const uint8_t IN_S1 = 33; // Toren FULL (aktif = penuh → stop)
const uint8_t IN_S2 = 32; // Sumur NO WATER (aktif = kering → dry-run)
const uint8_t IN_F1 = 27; // Fuse OK
const uint8_t IN_RESET = 26; // Reset button (long press)
const uint8_t OUT_K1 = 25; // Relay pompa
const uint8_t OUT_H1 = 18; // Hijau – Pompa nyala
const uint8_t OUT_H2 = 19; // Kuning – Standby / Recovery / Sumur kering
const uint8_t OUT_H3 = 23; // Merah – Fault / Lockout
const uint8_t BUZZER = 14;
// Relay & Buzzer polarity
#define RELAY_ON LOW
#define RELAY_OFF HIGH
#define BUZZER_ON LOW
#define BUZZER_OFF HIGH
// Timers (ms)
const uint32_t POWER_ON_DELAY_MS = 3000; // 3 detik tunda start pertama
const uint32_t RECOVERY_TIME = 15UL * 60 * 1000; // 15 menit
const uint32_t MAX_RECOVERY_TIME = 4UL * 3600 * 1000; // 4 jam max
const uint32_t MAX_RUN_TIME = 45UL * 60 * 1000; // 45 menit max run
const uint32_t SUCCESS_RUN_TIME = 45 * 1000; // 45 detik → reset counter
const uint32_t MIN_RUN_ENFORCE_TIME= 20 * 1000; // 20 detik anti-chatter float
const uint32_t MIN_OFF_TIME = 5 * 1000; // 5 detik anti short-cycle
const uint32_t SENSOR_STUCK_TIME = 14400UL * 1000; // 4 jam stuck detection
const uint32_t RESET_LONG_PRESS_MS = 3 * 1000;
const uint8_t MAX_RETRY_LIMIT = 3;
const uint32_t SCAN_INTERVAL = 100;
const uint32_t WDT_TIMEOUT_MS = 5000;
// ==========================================
// LOGGING
// ==========================================
enum DebugLevel { LOG_ERROR, LOG_WARN, LOG_INFO, LOG_DEBUG };
DebugLevel currentLevel = LOG_INFO;
const unsigned long LOG_INTERVAL = 500;
unsigned long lastLogTime = 0;
void logMessage(DebugLevel level, const char* format, ...) {
if (level > currentLevel) return;
unsigned long now = millis();
if (now - lastLogTime < LOG_INTERVAL) return;
lastLogTime = now;
char buffer[160];
va_list args;
va_start(args, format);
vsnprintf(buffer, sizeof(buffer), format, args);
va_end(args);
switch (level) {
case LOG_ERROR: Serial.print("[ERR] "); break;
case LOG_WARN: Serial.print("[WRN] "); break;
case LOG_INFO: Serial.print("[INF] "); break;
case LOG_DEBUG: Serial.print("[DBG] "); break;
}
Serial.println(buffer);
}
#define LOGE(...) logMessage(LOG_ERROR, __VA_ARGS__)
#define LOGW(...) logMessage(LOG_WARN, __VA_ARGS__)
#define LOGI(...) logMessage(LOG_INFO, __VA_ARGS__)
#define LOGD(...) logMessage(LOG_DEBUG, __VA_ARGS__)
// ==========================================
// VARIABEL GLOBAL
// ==========================================
enum SystemState { ST_INIT, ST_RUNNING, ST_RECOVERY, ST_FAULT, ST_LOCKOUT };
SystemState currentState = ST_INIT;
bool S1, S2, F1, btnReset;
bool K1_req = false;
bool minRunEnforced = false;
bool firstStartAllowed = false; // Power-on delay flag
uint8_t dryRunCounter = 0;
uint64_t totalRuntime = 0;
unsigned long lastScan = 0;
unsigned long stateTimer = 0;
unsigned long motorTimer = 0;
unsigned long s2StuckTimer = 0;
unsigned long resetPressStart = 0;
unsigned long recoveryStartTime = 0;
unsigned long successfulRunTimer = 0;
unsigned long lastStopTime = 0;
struct Debouncer {
uint8_t count = 0;
bool state = false;
bool update(bool raw) {
if (raw) { if (count < 5) count++; } else { if (count > 0) count--; }
if (count == 5) state = true;
else if (count == 0) state = false;
return state;
}
} dbS1, dbS2, dbF1, dbReset;
Preferences prefs;
// ==========================================
// FUNGSI
// ==========================================
void readInputs() {
S1 = INPUT_ACTIVE(dbS1.update(digitalRead(IN_S1)));
S2 = INPUT_ACTIVE(dbS2.update(digitalRead(IN_S2)));
F1 = INPUT_ACTIVE(dbF1.update(digitalRead(IN_F1)));
btnReset = INPUT_ACTIVE(dbReset.update(digitalRead(IN_RESET)));
}
void processFSM() {
if (!F1) {
if (currentState != ST_FAULT) {
currentState = ST_FAULT;
K1_req = false;
LOGE("FUSE PUTUS – Pompa dikunci");
}
return;
}
// S2 stuck detection (4 jam, hanya saat pompa mati)
if (S2 && !K1_req) {
if (s2StuckTimer == 0) s2StuckTimer = millis();
if (millis() - s2StuckTimer >= SENSOR_STUCK_TIME) {
currentState = ST_FAULT;
K1_req = false;
LOGE("S2 MACET >4 JAM (pompa mati) – Cek kabel/sensor sumur");
return;
}
} else {
s2StuckTimer = 0;
}
switch (currentState) {
case ST_INIT:
dryRunCounter = prefs.getUChar("retries", 0);
totalRuntime = prefs.getULong64("runtime", 0ULL);
readInputs();
if (S1 && S2) {
LOGW("STARTUP: Toren penuh + sumur kering – Mungkin keduanya kosong – Monitor");
}
currentState = ST_RUNNING;
LOGI("START | Retries sisa: %d | Runtime: %llu jam", dryRunCounter, totalRuntime / 3600);
break;
case ST_RUNNING:
// Power-on delay: hanya apply sekali setelah boot
if (!firstStartAllowed && millis() < POWER_ON_DELAY_MS) {
return;
}
firstStartAllowed = true;
// Pre-start: sumur kering → recovery
if (S2 && !K1_req) {
currentState = ST_RECOVERY;
stateTimer = millis();
if (recoveryStartTime == 0) recoveryStartTime = millis();
LOGW("Sumur kering sebelum start – Recovery %lu menit", RECOVERY_TIME / 60000UL);
return;
}
// Dry-run saat nyala
if (S2 && K1_req) {
dryRunCounter++;
prefs.putUChar("retries", dryRunCounter);
LOGW("DRY-RUN #%d/%d – Pompa dimatikan", dryRunCounter, MAX_RETRY_LIMIT);
if (dryRunCounter >= MAX_RETRY_LIMIT) {
currentState = ST_LOCKOUT;
LOGE("LOCKOUT – Dry-run %d kali", MAX_RETRY_LIMIT);
} else {
currentState = ST_RECOVERY;
stateTimer = millis();
if (recoveryStartTime == 0) recoveryStartTime = millis();
LOGI("RECOVERY – Dry-run #%d", dryRunCounter);
}
K1_req = false;
lastStopTime = millis();
successfulRunTimer = 0;
minRunEnforced = false;
return;
}
// Start logic
if (!S1 && !K1_req) {
if (millis() - lastStopTime >= MIN_OFF_TIME) {
K1_req = true;
motorTimer = millis();
successfulRunTimer = millis();
minRunEnforced = true;
LOGI("POMPA NYALA | Toren minta – Sejak stop: %lu detik", (millis() - lastStopTime) / 1000);
}
}
// Stop logic
else if (K1_req) {
bool forceContinue = minRunEnforced && (millis() - motorTimer < MIN_RUN_ENFORCE_TIME);
bool shouldStop = S1 || (millis() - motorTimer >= MAX_RUN_TIME);
if (shouldStop && !forceContinue) {
bool overRun = (millis() - motorTimer >= MAX_RUN_TIME);
unsigned long runDuration = millis() - motorTimer;
K1_req = false;
lastStopTime = millis();
motorTimer = millis();
minRunEnforced = false;
if (overRun) {
currentState = ST_FAULT;
LOGE("OVER-RUN >%lu menit – Cek S1/toren", MAX_RUN_TIME / 60000UL);
} else {
LOGI("POMPA MATI | Toren penuh | Durasi: %lu detik | Total: %llu jam",
runDuration / 1000, totalRuntime / 3600);
}
}
}
// Reset counter jika sukses
if (K1_req && (millis() - successfulRunTimer >= SUCCESS_RUN_TIME) && dryRunCounter > 0) {
dryRunCounter = 0;
prefs.putUChar("retries", 0);
LOGI("Counter dry-run di-reset – Stabil >%lu detik", SUCCESS_RUN_TIME / 1000);
}
// Akhiri enforced min run
if (K1_req && minRunEnforced && (millis() - motorTimer >= MIN_RUN_ENFORCE_TIME)) {
minRunEnforced = false;
}
break;
case ST_RECOVERY:
if (S2) {
stateTimer = millis();
}
if (millis() - recoveryStartTime >= MAX_RECOVERY_TIME) {
currentState = ST_FAULT;
LOGE("RECOVERY >4 jam – Sumur kering permanen?");
recoveryStartTime = 0;
return;
}
if (millis() - stateTimer >= RECOVERY_TIME) {
currentState = ST_RUNNING;
recoveryStartTime = 0;
LOGI("Recovery selesai – Normal kembali");
}
break;
case ST_FAULT:
case ST_LOCKOUT:
K1_req = false;
if (btnReset) {
if (resetPressStart == 0) resetPressStart = millis();
if (millis() - resetPressStart >= RESET_LONG_PRESS_MS) {
dryRunCounter = 0;
prefs.putUChar("retries", 0);
currentState = ST_INIT;
recoveryStartTime = 0;
LOGI("RESET (long press) – Sistem normal kembali");
resetPressStart = 0;
}
} else {
resetPressStart = 0;
}
break;
}
}
void writeBuzzer() {
unsigned long t = millis();
switch (currentState) {
case ST_FAULT: digitalWrite(BUZZER, (t % 2000 < 1000) ? BUZZER_ON : BUZZER_OFF); break;
case ST_LOCKOUT: digitalWrite(BUZZER, (t % 400 < 200) ? BUZZER_ON : BUZZER_OFF); break;
case ST_RECOVERY:
if (S2) digitalWrite(BUZZER, (t % 5000 < 500) ? BUZZER_ON : BUZZER_OFF);
else digitalWrite(BUZZER, (t % 2000 < 500) ? BUZZER_ON : BUZZER_OFF);
break;
default: digitalWrite(BUZZER, BUZZER_OFF); break;
}
}
void writeOutputs() {
digitalWrite(OUT_K1, K1_req ? RELAY_ON : RELAY_OFF);
digitalWrite(OUT_H1, (currentState == ST_RUNNING && K1_req) ? HIGH : LOW);
if (currentState == ST_RECOVERY) {
if (S2) digitalWrite(OUT_H2, (millis() % 500 < 250) ? HIGH : LOW);
else digitalWrite(OUT_H2, (millis() % 1000 < 500) ? HIGH : LOW);
} else {
digitalWrite(OUT_H2, (currentState == ST_RUNNING && !K1_req) ? HIGH : LOW);
}
if (currentState == ST_LOCKOUT) {
digitalWrite(OUT_H3, (millis() % 400 < 200) ? HIGH : LOW);
} else {
digitalWrite(OUT_H3, (currentState == ST_FAULT) ? HIGH : LOW);
}
writeBuzzer();
}
void trackRuntime() {
static unsigned long lastTick = 0;
if (K1_req && (millis() - lastTick >= 1000)) {
lastTick = millis();
totalRuntime++;
if (totalRuntime % 3600 == 0) {
prefs.putULong64("runtime", totalRuntime);
}
}
}
// ==========================================
// SETUP & LOOP
// ==========================================
void setup() {
Serial.begin(115200);
delay(150);
// Log boot reason
esp_reset_reason_t reason = esp_reset_reason();
const char* reasonStr = "Unknown";
switch (reason) {
case ESP_RST_POWERON: reasonStr = "Power-on"; break;
case ESP_RST_EXT: reasonStr = "External reset"; break;
case ESP_RST_SW: reasonStr = "Software reset"; break;
case ESP_RST_PANIC: reasonStr = "Exception/panic"; break;
case ESP_RST_INT_WDT: reasonStr = "Interrupt watchdog"; break;
case ESP_RST_TASK_WDT: reasonStr = "Task watchdog"; break;
case ESP_RST_WDT: reasonStr = "Other watchdog"; break;
case ESP_RST_DEEPSLEEP: reasonStr = "Deep sleep"; break;
case ESP_RST_BROWNOUT: reasonStr = "Brownout"; break;
case ESP_RST_SDIO: reasonStr = "SDIO"; break;
}
LOGI("BOOT REASON: %s", reasonStr);
// Watchdog
esp_task_wdt_config_t wdt_config = {
.timeout_ms = WDT_TIMEOUT_MS,
.idle_core_mask = 0,
.trigger_panic = true
};
esp_task_wdt_init(&wdt_config);
esp_task_wdt_add(NULL);
prefs.begin("pump-ctrl", false);
pinMode(IN_S1, INPUT_PULLUP);
pinMode(IN_S2, INPUT_PULLUP);
pinMode(IN_F1, INPUT_PULLUP);
pinMode(IN_RESET, INPUT_PULLUP);
pinMode(OUT_K1, OUTPUT); digitalWrite(OUT_K1, RELAY_OFF);
pinMode(OUT_H1, OUTPUT); digitalWrite(OUT_H1, LOW);
pinMode(OUT_H2, OUTPUT); digitalWrite(OUT_H2, LOW);
pinMode(OUT_H3, OUTPUT); digitalWrite(OUT_H3, LOW);
pinMode(BUZZER, OUTPUT); digitalWrite(BUZZER, BUZZER_OFF);
// Lamp test
digitalWrite(OUT_H1, HIGH); digitalWrite(OUT_H2, HIGH); digitalWrite(OUT_H3, HIGH);
delay(800);
digitalWrite(OUT_H1, LOW); digitalWrite(OUT_H2, LOW); digitalWrite(OUT_H3, LOW);
delay(700);
LOGI("V4.5 Production Freeze STARTED | Jetpump Dual-Tank Ready");
}
void loop() {
esp_task_wdt_reset();
if (millis() - lastScan >= SCAN_INTERVAL) {
lastScan = millis();
readInputs();
processFSM();
writeOutputs();
trackRuntime();
}
}