// ============================================================= // ESP32 Irrigation Controller — Budidaya Jamur // Mode : AUTO | MANUAL | OFFLINE // Sensor: SHT31 | Aktuator: Relay | MQTT: HiveMQ TLS // ============================================================= #include #include #include #include #include #include #include // ======================== CONFIGURATION (KONFIGURASI) ======================== // Konfigurasi WiFi #define WIFI_SSID "Hotel_Bintang_5" #define WIFI_PASSWORD "1sampai10" // Konfigurasi MQTT Broker (Cloud) #define MQTT_SERVER "7567af42f48f4f9f9fc63b41958f2517.s1.eu.hivemq.cloud" #define MQTT_PORT 8883 #define MQTT_USER "bimabuana" #define MQTT_PASS "25HikaruNakamura" // Identitas Unit #define DEVICE_ID "esp32-new-05" // Topik MQTT (Jalur Komunikasi) #define TOPIC_SENSOR "sensor/sht31" // Topik untuk mengirim data sensor static const String topicRelay = "cmd/relay/" + String(DEVICE_ID); // Topik menerima perintah pompa (ON/OFF) static const String topicMode = "cmd/mode/" + String(DEVICE_ID); // Topik menerima perubahan mode (auto/manual) static const String topicThreshold = "config/threshold/" + String(DEVICE_ID); // Topik menerima batas suhu/kelembapan baru static const String topicStatus = "status/" + String(DEVICE_ID); // Topik status keaktifan (LWT) // Konfigurasi Pin Perangkat Keras #define RELAY_PIN 23 // Pin yang terhubung ke relay pompa #define RELAY_ON LOW // Logika untuk menyalakan relay (Active Low) #define RELAY_OFF HIGH // Logika untuk mematikan relay // Konfigurasi Waktu (dalam Milidetik / ms) #define DURASI_RELAY 30000UL // Lama pompa menyiram: 30 detik #define DURASI_COOLDOWN 60000UL // Jeda sebelum pompa bisa menyiram lagi: 1 menit #define INTERVAL_SENSOR 3000UL // Interval membaca sensor SHT31: 3 detik #define INTERVAL_WIFI 10000UL // Interval mengecek koneksi WiFi: 10 detik #define INTERVAL_LCD 1000UL // Interval menyegarkan layar LCD: 1 detik // Batasan (Threshold) Bawaan Pabrik #define DEFAULT_TEMP_MAX 30.0 // Suhu maksimal (jika lebih, pompa nyala) #define DEFAULT_HUM_MIN 85.0 // Kelembapan minimal (jika kurang, pompa nyala) // ======================== ENUMS & GLOBALS (VARIABEL GLOBAL) ======================== // Tipe mode yang tersedia enum class DeviceMode { AUTO, MANUAL, OFFLINE }; DeviceMode currentMode = DeviceMode::AUTO; // Mode awal saat perangkat menyala // Objek Perangkat Keras LiquidCrystal_I2C lcd(0x27, 16, 2); // Alamat I2C layar LCD biasanya 0x27 Adafruit_SHT31 sht31; // Objek sensor suhu & kelembapan // Objek Koneksi Jaringan WiFiClientSecure espClient; // Client WiFi dengan keamanan TLS PubSubClient mqttClient(espClient); // Client MQTT // Variabel Penyimpan Data Sensor Terakhir float lastSuhu = 0.0; float lastHum = 0.0; // Variabel Pewaktu (Timer) untuk mengeksekusi tugas secara berkala tanpa "delay()" unsigned long lastSend = 0; unsigned long lastWifiCheck = 0; unsigned long lastLcdUpdate = 0; unsigned long lcdMsgStartTime = 0; // Waktu mulai pesan LCD sementara unsigned long lcdMsgDuration = 0; // Durasi pesan LCD sementara ditampilkan // Penanda apakah pompa sedang dikontrol paksa oleh jadwal (backend) bool scheduleActive = false; // Status Relay (Pompa) saat ini bool _relayState = false; // Variabel Penyimpan Nilai Batas Sensor (Threshold) float _tempMax = DEFAULT_TEMP_MAX; float _humMin = DEFAULT_HUM_MIN; // Penanda apakah ada perintah ganti mode dari aplikasi bool _hasModeChange = false; DeviceMode _pendingMode = DeviceMode::AUTO; // Penanda apakah ada perintah relay manual dari aplikasi bool _hasRelayCmd = false; bool _pendingRelayCmdOn = false; // Variabel Koneksi Ulang MQTT unsigned long lastMqttReconnectAttempt = 0; int mqttRetryCount = 0; // ======================== VARIABEL STATE (MODE AUTO) ======================== bool _autoRelayAktif = false; // Sedang dalam proses menyiram? bool _autoCooldownAktif = false; // Sedang dalam masa jeda (cooldown)? unsigned long _autoWaktuMulaiRelay = 0; // Waktu saat pompa mulai menyala unsigned long _autoWaktuMulaiCooldown = 0; // Waktu saat masa jeda dimulai // ======================== VARIABEL STATE (MODE OFFLINE) ======================== bool _offlineRelayAktif = false; bool _offlineCooldownAktif = false; unsigned long _offlineWaktuMulaiRelay = 0; unsigned long _offlineWaktuMulaiCooldown = 0; // ======================== PROTOTYPES (DEKLARASI FUNGSI AWAL) ======================== void autoModeEnter(); void manualModeEnter(); void offlineModeEnter(); const char* autoModeRun(float suhu, float hum); const char* manualModeRun(); const char* offlineModeRun(float suhu, float hum); // ======================== LCD HELPERS (FUNGSI LAYAR LCD) ======================== // Menampilkan pesan sementara di layar LCD (contoh: "Pompa Menyala!") void showLcdMsg(const char* l0, const char* l1, unsigned long durasi) { lcd.clear(); lcd.setCursor(0, 0); lcd.print(l0); // Baris 1 lcd.setCursor(0, 1); lcd.print(l1); // Baris 2 lcdMsgStartTime = millis(); lcdMsgDuration = durasi; } // Mengecek apakah waktu tayang pesan sementara di LCD sudah habis bool lcdMsgExpired() { return (millis() - lcdMsgStartTime) >= lcdMsgDuration; } // ======================== RELAY (FUNGSI POMPA AIR) ======================== void relayBegin() { pinMode(RELAY_PIN, OUTPUT); digitalWrite(RELAY_PIN, RELAY_OFF); _relayState = false; } void relayOn() { digitalWrite(RELAY_PIN, RELAY_ON); _relayState = true; Serial.println("[ACTUATOR] Relay POMPA -> HIDUP (ON)"); } void relayOff() { digitalWrite(RELAY_PIN, RELAY_OFF); _relayState = false; Serial.println("[ACTUATOR] Relay POMPA -> MATI (OFF)"); } // Mengambil status pompa saat ini (true = ON, false = OFF) bool relayState() { return _relayState; } // ======================== SENSOR (FUNGSI SHT31) ======================== bool sensorBegin() { return sht31.begin(0x44); // 0x44 adalah alamat I2C default untuk SHT31 } // Membaca suhu dan kelembapan. Mengembalikan 'true' jika berhasil. bool sensorRead(float &suhu, float &hum) { float rawSuhu = sht31.readTemperature(); float rawHum = sht31.readHumidity(); if (!isnan(rawSuhu) && !isnan(rawHum)) { suhu = rawSuhu; hum = rawHum; // Batasi nilai kelembapan agar tetap berada di rentang valid 0% - 100% if (hum > 100.0) hum = 100.0; if (hum < 0.0) hum = 0.0; return true; } return false; } // ======================== MQTT (FUNGSI KOMUNIKASI CLOUD) ======================== // Fungsi yang dipanggil otomatis saat alat menerima pesan MQTT dari server void onMessage(char* topic, byte* payload, unsigned int length) { // Cegah fragmentasi memori (Memory Leak), gunakan static/lokal buffer unsigned int len = length < 255 ? length : 255; char buf[256]; memcpy(buf, payload, len); buf[len] = '\0'; // Tambahkan penutup string karakter String msg = String(buf); String t = String(topic); // Jika pesan adalah instruksi ganti mode if (t == topicMode) { if (msg == "auto") { _pendingMode = DeviceMode::AUTO; _hasModeChange = true; } else if (msg == "manual") { _pendingMode = DeviceMode::MANUAL; _hasModeChange = true; } else if (msg == "offline") { _pendingMode = DeviceMode::OFFLINE; _hasModeChange = true; } Serial.println("[MQTT] Mode request: " + msg); return; } // Jika pesan adalah instruksi menyalakan/mematikan pompa (khusus mode MANUAL) if (t == topicRelay) { if (msg == "ON") { _pendingRelayCmdOn = true; _hasRelayCmd = true; } else if (msg == "OFF") { _pendingRelayCmdOn = false; _hasRelayCmd = true; } Serial.println("[MQTT] Relay cmd: " + msg); return; } // Jika pesan adalah instruksi mengubah batas suhu & kelembapan if (t == topicThreshold) { StaticJsonDocument<128> doc; if (!deserializeJson(doc, msg)) { // Parse pesan JSON if (doc.containsKey("temp")) { float v = doc["temp"].as(); if (v > 0 && v < 100) _tempMax = v; } if (doc.containsKey("hum")) { float v = doc["hum"].as(); if (v > 0 && v <= 100) _humMin = v; } Serial.printf("[MQTT] Threshold: temp=%.1f hum=%.1f\n", _tempMax, _humMin); } } } // Fungsi untuk menyambungkan kembali koneksi MQTT secara non-blocking void reconnect() { // Coba menyambung setiap 5 detik tanpa membuat seluruh sistem freeze (delay) if (millis() - lastMqttReconnectAttempt > 5000) { lastMqttReconnectAttempt = millis(); Serial.print("[MQTT] Menghubungkan..."); // Jika berhasil nyambung dengan konfigurasi LWT (Last Will and Testament) if (mqttClient.connect(DEVICE_ID, MQTT_USER, MQTT_PASS, topicStatus.c_str(), 1, true, "offline")) { Serial.println("OK"); mqttRetryCount = 0; // Reset counter percobaan // Kirim status online segera setelah berhasil terhubung mqttClient.publish(topicStatus.c_str(), "online", true); // Berlangganan kembali ke topik agar bisa menerima perintah mqttClient.subscribe(topicRelay.c_str()); mqttClient.subscribe(topicMode.c_str()); mqttClient.subscribe(topicThreshold.c_str()); } else { // Jika gagal Serial.printf("GAGAL rc=%d\n", mqttClient.state()); // Alat akan terus mencoba tanpa restart, membiarkan relay OFFLINE menjaga tanaman } } } void mqttBegin() { espClient.setInsecure(); // Karena menggunakan TLS (port 8883) tanpa verifikasi sertifikat root mqttClient.setServer(MQTT_SERVER, MQTT_PORT); mqttClient.setCallback(onMessage); // Daftarkan fungsi pemroses pesan mqttClient.setBufferSize(512); // Ukuran buffer cukup untuk pesan sensor JSON } // Fungsi perawatan MQTT, dipanggil terus-menerus di loop() void mqttLoop() { if (!mqttClient.connected()) { reconnect(); } else { mqttClient.loop(); // Proses pesan masuk } } bool mqttConnected() { return mqttClient.connected(); } // Mengirim data sensor (suhu, kelembapan, status relay) ke server void mqttPublish(float suhu, float hum, bool rOn, const char* modeStr) { if (!mqttClient.connected()) return; // Jangan kirim kalau sedang terputus StaticJsonDocument<256> doc; doc["temp"] = round(suhu * 10.0) / 10.0; // Format 1 angka desimal doc["hum"] = round(hum * 10.0) / 10.0; doc["device_id"] = DEVICE_ID; doc["relay"] = rOn; doc["mode"] = modeStr; char buf[256]; serializeJson(doc, buf); // Ubah objek JSON jadi string if (mqttClient.publish(TOPIC_SENSOR, buf)) { Serial.println("[MQTT] Publish OK: " + String(buf)); } else { Serial.println("[MQTT] Publish GAGAL"); } } // ==== FUNGSI PENGAMBIL DATA MQTT ==== bool mqttHasModeChange(DeviceMode &out) { if (!_hasModeChange) return false; out = _pendingMode; _hasModeChange = false; // Reset status setelah data diambil return true; } bool mqttHasRelayCmd(bool &out) { if (!_hasRelayCmd) return false; out = _pendingRelayCmdOn; _hasRelayCmd = false; // Reset status setelah data diambil return true; } float mqttGetTempMax() { return _tempMax; } float mqttGetHumMin() { return _humMin; } // ======================== MODES LOGIC (LOGIKA MASING-MASING MODE) ======================== // ---- MODE AUTO ---- void autoModeEnter() { _autoRelayAktif = false; _autoCooldownAktif = false; relayOff(); Serial.println("[AUTO] Mode entered"); } const char* autoModeRun(float suhu, float hum) { const char* modeStr = "auto"; // 1. Jika pompa sedang menyiram if (_autoRelayAktif) { modeStr = "auto-on"; unsigned long elapsed = millis() - _autoWaktuMulaiRelay; // Update LCD hitung mundur sekarang dipindah ke timer 1 detik di loop() // Jika waktu menyiram sudah habis if (elapsed >= DURASI_RELAY) { if (!scheduleActive) { // Hanya matikan pompa jika jadwal backend tidak mengunci relayOff(); showLcdMsg("Relay OFF", "Cooldown...", 1500); } _autoRelayAktif = false; _autoCooldownAktif = true; // Mulai masa jeda (Cooldown) _autoWaktuMulaiCooldown = millis(); Serial.println("[AUTO] Selesai siram, masuk cooldown"); } // 2. Jika sedang dalam masa Jeda (Cooldown) } else if (_autoCooldownAktif) { modeStr = "cooldown"; unsigned long elapsed = millis() - _autoWaktuMulaiCooldown; // Update LCD hitung mundur sekarang dipindah ke timer 1 detik di loop() // Jika waktu jeda sudah habis, kembali memantau if (elapsed >= DURASI_COOLDOWN) { _autoCooldownAktif = false; showLcdMsg("Siap", "Monitoring...", 800); Serial.println("[AUTO] Cooldown selesai, monitoring"); } // 3. Status Siaga (Memantau Sensor) } else { float tempMax = mqttGetTempMax(); float humMin = mqttGetHumMin(); // Syarat menyiram: suhu melampaui batas ATAU kelembapan berada di bawah batas bool kondisi = (suhu > tempMax) || (hum < humMin); // Pengaman: Jika sensor error (membaca 0.0), JANGAN nyalakan pompa untuk mencegah banjir if (suhu == 0.0 && hum == 0.0) { kondisi = false; } if (kondisi) { if (!scheduleActive) { // Nyalakan pompa jika tidak ada intervensi jadwal relayOn(); showLcdMsg("Relay ON!", suhu > tempMax ? "Suhu tinggi" : "Humid rendah", 1500); Serial.printf("[AUTO] Relay ON! suhu=%.1f hum=%.1f\n", suhu, hum); } // Pindahkan state (status) menjadi "Menyiram" _autoRelayAktif = true; _autoWaktuMulaiRelay = millis(); modeStr = "auto-on"; } else { if (lcdMsgExpired()) { lcd.setCursor(0, 1); lcd.print("[A] Monitoring "); // Status di layar LCD } } } return modeStr; } // ---- MODE MANUAL ---- void manualModeEnter() { relayOff(); Serial.println("[MANUAL] Mode entered, relay OFF"); showLcdMsg("[MANUAL]", "Menunggu CMD...", 1500); } const char* manualModeRun() { bool cmdOn; // Membaca perintah manual via MQTT. (Abaikan jika jadwal sedang aktif) if (!scheduleActive && mqttHasRelayCmd(cmdOn)) { if (cmdOn) { relayOn(); showLcdMsg("[M] POMPA ON", "Manual override", 1500); Serial.println("[MANUAL] Relay ON"); } else { relayOff(); showLcdMsg("[M] POMPA OFF", "Manual override", 1500); Serial.println("[MANUAL] Relay OFF"); } } // Tampilkan status on/off ke layar LCD if (lcdMsgExpired()) { lcd.setCursor(0, 1); lcd.print(relayState() ? "[M] POMPA ON " : "[M] POMPA OFF "); } return relayState() ? "manual-on" : "manual-off"; } // ---- MODE OFFLINE ---- // Mode darurat saat MQTT/Internet putus. Mirip AUTO, namun beroperasi tanpa komunikasi cloud void offlineModeEnter() { _offlineRelayAktif = false; _offlineCooldownAktif = false; relayOff(); Serial.println("[OFFLINE] Mode entered"); showLcdMsg("[OFFLINE]", "Auto no MQTT", 1500); } const char* offlineModeRun(float suhu, float hum) { const char* modeStr = "offline"; if (_offlineRelayAktif) { modeStr = "offline-on"; unsigned long elapsed = millis() - _offlineWaktuMulaiRelay; // Update LCD dipindah ke loop() per detik if (elapsed >= DURASI_RELAY) { relayOff(); _offlineRelayAktif = false; _offlineCooldownAktif = true; _offlineWaktuMulaiCooldown = millis(); showLcdMsg("Relay OFF", "Cooldown...", 1500); Serial.println("[OFFLINE] Selesai siram, cooldown"); } } else if (_offlineCooldownAktif) { modeStr = "offline-cooldown"; unsigned long elapsed = millis() - _offlineWaktuMulaiCooldown; // Update LCD dipindah ke loop() per detik if (elapsed >= DURASI_COOLDOWN) { _offlineCooldownAktif = false; showLcdMsg("[OFFLINE]", "Monitoring...", 800); Serial.println("[OFFLINE] Cooldown selesai"); } } else { // Tetap menggunakan angka threshold terakhir dari variabel global float tempMax = mqttGetTempMax(); float humMin = mqttGetHumMin(); bool kondisi = (suhu > tempMax) || (hum < humMin); // Pengaman: Jika sensor error (membaca 0.0), JANGAN nyalakan pompa untuk mencegah banjir if (suhu == 0.0 && hum == 0.0) { kondisi = false; } if (kondisi) { relayOn(); _offlineRelayAktif = true; _offlineWaktuMulaiRelay = millis(); modeStr = "offline-on"; showLcdMsg("Relay ON!", suhu > tempMax ? "Suhu tinggi" : "Humid rendah", 1500); Serial.printf("[OFFLINE] Relay ON! suhu=%.1f hum=%.1f\n", suhu, hum); } else { if (lcdMsgExpired()) { lcd.setCursor(0, 1); lcd.print("[OFL] Monitor "); } } } return modeStr; } // ======================== MAIN (FUNGSI UTAMA ARDUINO) ======================== // Mengelola koneksi WiFi di awal nyala bool setupWifi() { WiFi.begin(WIFI_SSID, WIFI_PASSWORD); lcd.clear(); lcd.setCursor(0, 0); lcd.print("Connecting WiFi"); lcd.setCursor(0, 1); lcd.print("Timeout: 120s"); unsigned long start = millis(); unsigned long elapsed = 0; // Tunggu maksimal 120 detik (2 menit) while (WiFi.status() != WL_CONNECTED) { elapsed = (millis() - start) / 1000; lcd.setCursor(0, 1); lcd.print("Sisa: "); lcd.print(120 - elapsed); lcd.print("s "); if (elapsed >= 120) { // Jika melebihi waktu tunggu, sistem masuk mode offline lcd.clear(); lcd.setCursor(0, 0); lcd.print("WiFi Timeout!"); lcd.setCursor(0, 1); lcd.print("Mode: OFFLINE"); Serial.println("[WIFI] Timeout 2 menit, masuk OFFLINE"); delay(1500); return false; } delay(500); } lcd.clear(); lcd.setCursor(0, 0); lcd.print("WiFi Connected!"); lcd.setCursor(0, 1); lcd.print(WiFi.localIP()); // Tampilkan IP Alat Serial.println("[WIFI] Terhubung: " + WiFi.localIP().toString()); delay(1500); return true; } // Memindahkan operasional alat dari satu mode ke mode lain void switchMode(DeviceMode newMode) { if (newMode == currentMode) return; currentMode = newMode; switch (currentMode) { case DeviceMode::AUTO: autoModeEnter(); break; case DeviceMode::MANUAL: manualModeEnter(); break; case DeviceMode::OFFLINE: offlineModeEnter(); break; } Serial.print("[MAIN] Perubahan Mode -> "); Serial.println( currentMode == DeviceMode::AUTO ? "AUTO" : currentMode == DeviceMode::MANUAL ? "MANUAL" : "OFFLINE"); } // setup() dieksekusi 1x saat alat baru dihidupkan (Boot) void setup() { Serial.begin(115200); // Buka komunikasi serial monitor Wire.begin(21, 22); // Konfigurasi pin I2C (SDA=21, SCL=22) untuk LCD & Sensor relayBegin(); lcd.init(); lcd.backlight(); if (!sensorBegin()) { lcd.clear(); lcd.print("SHT31 Error!"); Serial.println("[MAIN] SHT31 tidak ditemukan! Melanjutkan tanpa sensor..."); delay(3000); // Tampilkan pesan selama 3 detik lalu lanjut berjalan } bool wifiOk = setupWifi(); mqttBegin(); // Setup konfigurasi koneksi Cloud (belum konek) // Pilih mode awal berdasarkan ketersediaan jaringan if (wifiOk) { currentMode = DeviceMode::AUTO; autoModeEnter(); showLcdMsg("[AUTO]", "Monitoring...", 1500); Serial.println("[MAIN] Start -> AUTO"); } else { currentMode = DeviceMode::OFFLINE; offlineModeEnter(); Serial.println("[MAIN] Start -> OFFLINE"); } } // loop() dieksekusi berulang-ulang selamanya dengan cepat void loop() { // 1. WiFi Management (Memelihara Koneksi WiFi) if (millis() - lastWifiCheck > INTERVAL_WIFI) { lastWifiCheck = millis(); if (WiFi.status() != WL_CONNECTED) { Serial.println("[MAIN] WiFi putus, reconnect..."); WiFi.disconnect(); WiFi.begin(WIFI_SSID, WIFI_PASSWORD); } else if (currentMode == DeviceMode::OFFLINE && !mqttConnected()) { Serial.println("[MAIN] WiFi pulih saat OFFLINE, reconnect MQTT..."); mqttBegin(); showLcdMsg("[OFFLINE]", "WiFi pulih!", 1500); } } // 2. MQTT Management (Mengurus Pesan Masuk & Putus Nyambung Cloud) mqttLoop(); // 3. Memproses Permintaan Ganti Mode via Aplikasi DeviceMode requestedMode; if (mqttHasModeChange(requestedMode)) { switchMode(requestedMode); } // 3b. Memproses Sistem Jadwal Paksa (Schedule Override) if (currentMode != DeviceMode::OFFLINE) { bool cmdOn; if (mqttHasRelayCmd(cmdOn)) { if (cmdOn && !scheduleActive) { // Jadwal mulai scheduleActive = true; // Bersihkan state (waktu & timer) di balik layar agar mode Auto tidak diam-diam menghitung _autoRelayAktif = false; _autoCooldownAktif = false; _offlineRelayAktif = false; _offlineCooldownAktif = false; relayOn(); // Ambil alih kendali pompa paksa ON showLcdMsg("[JADWAL] ON", "Sch override", 1500); Serial.println("[SCH] Jadwal ON -> relay dikunci"); } else if (!cmdOn && scheduleActive) { // Jadwal usai scheduleActive = false; relayOff(); showLcdMsg("[JADWAL] OFF", "Kembali ke mode", 1500); Serial.println("[SCH] Jadwal OFF -> kembali ke mode aktif"); // Kembalikan ke mode awal setelah jadwal selesai switch (currentMode) { case DeviceMode::AUTO: autoModeEnter(); break; case DeviceMode::MANUAL: manualModeEnter(); break; default: break; } } } } // 4. Proses Pembacaan Sensor dan Evaluasi Timer Rutin if (millis() - lastSend >= INTERVAL_SENSOR) { // Setiap 3 detik lastSend = millis(); float suhu, hum; bool sensorOk = sensorRead(suhu, hum); // Cegah sistem stuck/diam jika terjadi error pembacaan sensor mendadak (Kabel Goyang) if (!sensorOk) { Serial.println("[MAIN] Sensor error: NaN, mencoba inisialisasi ulang..."); sensorBegin(); // Coba inisialisasi ulang agar bisa pulih saat sensor dicolok lagi suhu = lastSuhu; // Gunakan angka valid sebelumnya hum = lastHum; } else { lastSuhu = suhu; lastHum = hum; } const char* modeStr = "auto"; // Jalankan logika operasional sesuai mode yang aktif switch (currentMode) { case DeviceMode::AUTO: modeStr = scheduleActive ? "schedule-on" : autoModeRun(suhu, hum); // Tetap publish status agar backend dapat memonitor perangkat if(sensorOk) mqttPublish(suhu, hum, relayState(), modeStr); break; case DeviceMode::MANUAL: modeStr = scheduleActive ? "schedule-on" : manualModeRun(); if(sensorOk) mqttPublish(suhu, hum, relayState(), modeStr); break; case DeviceMode::OFFLINE: modeStr = offlineModeRun(suhu, hum); Serial.printf("[OFFLINE] suhu=%.1f hum=%.1f relay=%s\n", suhu, hum, relayState() ? "ON" : "OFF"); break; } // Update layar LCD jika sedang berada di dalam Jadwal paksa if (scheduleActive && lcdMsgExpired()) { lcd.setCursor(0, 1); lcd.print("[SCH] POMPA ON "); } } // 5. Menyegarkan Nilai Suhu & Kelembapan di Layar LCD if (millis() - lastLcdUpdate >= INTERVAL_LCD) { // Setiap 1 detik lastLcdUpdate = millis(); // Pastikan tidak menimpa pesan sementara (seperti "Relay ON!") if (lcdMsgExpired()) { // --- Update Baris 1: Suhu & Kelembapan --- lcd.setCursor(0, 0); if (lastSuhu == 0.0 && lastHum == 0.0) { lcd.print("T:-- H:-- "); // Jika data kosong/baru nyala } else { lcd.print("T:"); lcd.print(lastSuhu, 1); // Desimal 1 angka lcd.print(" H:"); lcd.print(lastHum, 0); // Tanpa desimal lcd.print("% "); } // --- Update Baris 2: Hitung Mundur (Countdown) Mulus 1 Detik --- if (!scheduleActive) { // Jangan timpa jika sedang jadwal paksa if (currentMode == DeviceMode::AUTO) { if (_autoRelayAktif) { unsigned long el = millis() - _autoWaktuMulaiRelay; unsigned long sisa = (el < DURASI_RELAY) ? (DURASI_RELAY - el) / 1000 : 0; lcd.setCursor(0, 1); lcd.print("Siram "); lcd.print(sisa); lcd.print("s "); } else if (_autoCooldownAktif) { unsigned long el = millis() - _autoWaktuMulaiCooldown; unsigned long sisa = (el < DURASI_COOLDOWN) ? (DURASI_COOLDOWN - el) / 1000 : 0; lcd.setCursor(0, 1); lcd.print("Jeda "); lcd.print(sisa); lcd.print("s "); } } else if (currentMode == DeviceMode::OFFLINE) { if (_offlineRelayAktif) { unsigned long el = millis() - _offlineWaktuMulaiRelay; unsigned long sisa = (el < DURASI_RELAY) ? (DURASI_RELAY - el) / 1000 : 0; lcd.setCursor(0, 1); lcd.print("Siram "); lcd.print(sisa); lcd.print("s [OFL]"); } else if (_offlineCooldownAktif) { unsigned long el = millis() - _offlineWaktuMulaiCooldown; unsigned long sisa = (el < DURASI_COOLDOWN) ? (DURASI_COOLDOWN - el) / 1000 : 0; lcd.setCursor(0, 1); lcd.print("Jeda "); lcd.print(sisa); lcd.print("s [OFL]"); } } } } } }