#include #include #include #include #include #include // ========================================== // CONFIGURATION // ========================================== const char* host_api = "https://identia.montaklo.id/api_wifi.php"; unsigned long lastPollTime = 0; const unsigned long pollInterval = 3000; // ========================================== // RECONNECT & AP MODE SETTINGS // ========================================== unsigned long lastReconnectAttempt = 0; const unsigned long reconnectInterval = 15000; bool portalActive = false; bool isConnectingToNewWiFi = false; bool shouldRestart = false; unsigned long restartTime = 0; WiFiManager wm; Preferences prefs; // Saved credentials from NVS String savedSSID = ""; String savedPass = ""; // ========================================== // WIFI EVENT FLAGS (untuk deteksi password salah / SSID tidak ada / sinyal lemah) // ESP32 WiFi.status() TIDAK reliably mengembalikan WL_CONNECT_FAILED // saat password salah. Harus pakai WiFi Event Handler. // ========================================== volatile bool wifiAuthFailed = false; // reason 202: pasti password salah volatile bool wifiHandshakeTimeout = false; // reason 15/204: bisa password salah ATAU sinyal lemah volatile bool wifiSsidNotFound = false; // reason 201: SSID tidak ditemukan void onWiFiDisconnectEvent(WiFiEvent_t event, WiFiEventInfo_t info) { if (event == ARDUINO_EVENT_WIFI_STA_DISCONNECTED) { uint8_t reason = info.wifi_sta_disconnected.reason; Serial.printf("[WIFI-EVENT] Disconnected, reason: %d\n", reason); // Reason codes dari ESP-IDF: // WIFI_REASON_AUTH_FAIL (202) = password salah (PASTI) // WIFI_REASON_4WAY_HANDSHAKE_TIMEOUT (15) = bisa password salah ATAU sinyal lemah // WIFI_REASON_HANDSHAKE_TIMEOUT (204) = bisa password salah ATAU sinyal lemah // WIFI_REASON_NO_AP_FOUND (201) = SSID tidak ditemukan if (reason == WIFI_REASON_AUTH_FAIL) { wifiAuthFailed = true; Serial.println("[WIFI-EVENT] >>> AUTH FAILED (password salah!)"); } else if (reason == 15 || reason == 204) { // 4WAY_HANDSHAKE_TIMEOUT / HANDSHAKE_TIMEOUT // Ini AMBIGU — bisa password salah atau sinyal terlalu lemah // Akan di-resolve nanti berdasarkan RSSI target wifiHandshakeTimeout = true; Serial.println("[WIFI-EVENT] >>> HANDSHAKE TIMEOUT (perlu cek RSSI)"); } else if (reason == WIFI_REASON_NO_AP_FOUND) { wifiSsidNotFound = true; Serial.println("[WIFI-EVENT] >>> SSID NOT FOUND"); } } } // Forward declarations void pollServer(); void processCommand(String jsonPayload); void scanAndSendResults(); void reportChangeStatus(const char* status, const char* msg, const char* reason = ""); void startPortal(); void stopPortal(); void tryReconnectOldWiFi(); void saveCredentials(const String& ssid, const String& pass); void loadCredentials(); void saveConfigCallback(); // ========================================== // CALLBACK WIFIMANAGER: SIMPAN KREDENSIAL BARU // ========================================== void saveConfigCallback() { Serial.println("[CALLBACK] Menyimpan pengaturan Wi-Fi baru..."); isConnectingToNewWiFi = true; // Ambil kredensial dari form String newSSID = wm.getWiFiSSID(); String newPass = wm.getWiFiPass(); Serial.print("SSID Baru: "); Serial.println(newSSID); // Timpa ke memori NVS langsung saveCredentials(newSSID, newPass); // Putuskan koneksi lama (clean state) Serial.println("[CALLBACK] Kredensial ditekan! Menunggu 2 detik untuk Safe Reboot..."); shouldRestart = true; restartTime = millis(); } // ========================================== // NVS: SIMPAN & BACA KREDENSIAL WIFI // ========================================== void saveCredentials(const String& ssid, const String& pass) { prefs.begin("wifi-cfg", false); prefs.putString("ssid", ssid); prefs.putString("pass", pass); prefs.end(); Serial.println("[NVS] Kredensial tersimpan: " + ssid); } void loadCredentials() { prefs.begin("wifi-cfg", true); savedSSID = prefs.getString("ssid", ""); savedPass = prefs.getString("pass", ""); prefs.end(); Serial.println("[NVS] Kredensial terbaca: " + savedSSID); } // ========================================== // CUSTOM CSS — Enterprise Corporate Network Server Style // ========================================== const char PORTAL_CSS[] PROGMEM = R"rawliteral( )rawliteral"; // ========================================== // FUNGSI: MULAI AP HOTSPOT (NON-BLOCKING) // ========================================== void startPortal() { if (portalActive) return; Serial.println("========================================="); Serial.println("STARTING AP HOTSPOT: @Identia.id"); Serial.println("Connect -> 192.168.4.1"); Serial.println("========================================="); // TWEAK PENTING: Disconnect (tanpa true) + delay tepat sebelum mengatur mode AP_STA // Ini memastikan radio station mati, auto-reconnect terputus, dan radio idle untuk scan WiFi.disconnect(); delay(200); // Set mode AP_STA agar radio siap untuk scan WiFi.mode(WIFI_AP_STA); delay(100); wm.setCustomHeadElement(PORTAL_CSS); wm.setTitle("@Identia.id"); wm.setConfigPortalBlocking(false); wm.setConfigPortalTimeout(0); // Portal aktif tanpa batas waktu std::vector menuItems = {"wifi", "info", "update", "restart", "close"}; wm.setMenu(menuItems); // Set Callback untuk NVS & Clean Disconnect saat "Save" wm.setSaveConfigCallback(saveConfigCallback); wm.startConfigPortal("@Identia.id"); portalActive = true; Serial.println("AP aktif di 192.168.4.1 — Reconnect berjalan di background..."); } // ========================================== // FUNGSI: MATIKAN AP HOTSPOT // ========================================== void stopPortal() { if (!portalActive) return; Serial.println("========================================="); Serial.println("WiFi TERHUBUNG! Menonaktifkan AP Hotspot..."); Serial.println("========================================="); wm.stopConfigPortal(); WiFi.mode(WIFI_STA); portalActive = false; } // ========================================== // FUNGSI: RECONNECT KE WIFI TERSIMPAN (NVS) // ========================================== void tryReconnectOldWiFi() { if (WiFi.status() == WL_CONNECTED) return; unsigned long now = millis(); if (now - lastReconnectAttempt < reconnectInterval) return; lastReconnectAttempt = now; if (savedSSID.length() > 0) { Serial.println("[RECONNECT] Mencoba connect ke: " + savedSSID); WiFi.disconnect(); delay(50); WiFi.begin(savedSSID.c_str(), savedPass.c_str()); } else { Serial.println("[RECONNECT] Tidak ada kredensial tersimpan, coba WiFi.reconnect()"); WiFi.disconnect(); delay(50); WiFi.reconnect(); } } // ========================================== // SETUP // ========================================== void setup() { Serial.begin(115200); Serial.println("\n=========================================="); Serial.println("@Identia.id Smart Door - Booting..."); Serial.println("=========================================="); // ----------------------------------------------- // LANGKAH 1: Baca kredensial tersimpan dari NVS // ----------------------------------------------- loadCredentials(); // ----------------------------------------------- // LANGKAH 2: Coba connect ke WiFi tersimpan DULU // Gunakan NVS credentials jika tersedia // ----------------------------------------------- // Register WiFi event handler untuk deteksi password salah WiFi.onEvent(onWiFiDisconnectEvent); WiFi.mode(WIFI_STA); if (savedSSID.length() > 0) { Serial.println("Connecting ke NVS WiFi: " + savedSSID); WiFi.begin(savedSSID.c_str(), savedPass.c_str()); } else { Serial.println("Mencoba connect ke WiFi default tersimpan..."); WiFi.begin(); } // ----------------------------------------------- // LANGKAH 3: Tunggu percobaan koneksi awal (Tanpa Portal) // ----------------------------------------------- int attempts = 0; const int maxBootAttempts = 30; // 15 detik (30 x 500ms) while (WiFi.status() != WL_CONNECTED && attempts < maxBootAttempts) { delay(500); Serial.print("."); attempts++; } Serial.println(); // ----------------------------------------------- // LANGKAH 4: Cek hasil, nyalakan Portal jika gagal // ----------------------------------------------- if (WiFi.status() == WL_CONNECTED) { String connSSID = WiFi.SSID(); String connPass = WiFi.psk(); Serial.println("========================================="); Serial.println("BERHASIL terhubung ke WiFi!"); Serial.print("SSID: "); Serial.println(connSSID); Serial.print("IP : "); Serial.println(WiFi.localIP()); Serial.println("========================================="); // Simpan ke NVS supaya auto-reconnect berikutnya berhasil saveCredentials(connSSID, connPass); } else { Serial.println("========================================="); Serial.println("Gagal terhubung ke WiFi tersimpan."); Serial.println("Buka 192.168.4.1 untuk pilih WiFi."); Serial.println("========================================="); // Start Portal di sini, setelah WiFi.begin benar-benar gagal (atau timeout) // startPortal() sekarang akan memiliki radio yang idle dan fresh untuk di-scan. startPortal(); } } // ========================================== // LOOP UTAMA // ========================================== void loop() { // Mekanisme Reboot Aman pasca Save Config if (shouldRestart && millis() - restartTime > 2000) { Serial.println("[REBOOT] Restarting ESP32 sekarang untuk menyambung ke jaringan baru..."); ESP.restart(); } // Selalu proses portal jika aktif if (portalActive) { wm.process(); } if (WiFi.status() == WL_CONNECTED) { // WiFi terhubung -> matikan AP jika masih aktif if (portalActive) { if (isConnectingToNewWiFi) { Serial.println("[LOOP] Berhasil terhubung ke Wi-Fi baru dari portal."); isConnectingToNewWiFi = false; } stopPortal(); // Simpan kredensial saat baru terhubung (misal via portal) saveCredentials(WiFi.SSID(), WiFi.psk()); } // Poll server untuk command if (millis() - lastPollTime >= pollInterval) { lastPollTime = millis(); pollServer(); } } else { // Logging kegagalan koneksi jika dari portal if (isConnectingToNewWiFi && (WiFi.status() == WL_CONNECT_FAILED || WiFi.status() == WL_NO_SSID_AVAIL || WiFi.status() == WL_IDLE_STATUS)) { Serial.println("[LOOP] Menunggu atau gagal terhubung ke Wi-Fi baru..."); // Jangan set isConnectingToNewWiFi = false langsung, biarkan flag aktif agar background reconnect tertahan. // WiFiManager form portal akan memproses fallback fallback mode AP dengan sendirinya. } // WiFi terputus -> nyalakan AP jika belum aktif (kecuali sedang menghubungkan Wi-Fi baru) if (!portalActive && !isConnectingToNewWiFi) { startPortal(); } // CEGAH BENTROK: Hanya lakukan reconnect manual jika Portal TIDAK aktif // dan TIDAK sedang mencoba menghubungkan Wi-Fi baru dari portal if (!portalActive && !isConnectingToNewWiFi) { tryReconnectOldWiFi(); } } } void pollServer() { HTTPClient http; String current_ssid = WiFi.SSID(); current_ssid.replace(" ", "%20"); String url = String(host_api) + "?action=esp_poll&ssid=" + current_ssid + "&ip=" + WiFi.localIP().toString() + "&rssi=" + String(WiFi.RSSI()); http.begin(url); int httpCode = http.GET(); if (httpCode > 0) { if (httpCode == HTTP_CODE_OK) { String payload = http.getString(); processCommand(payload); } } else { Serial.printf("[HTTP] GET failed: %s\n", http.errorToString(httpCode).c_str()); } http.end(); } void reportChangeStatus(const char* status, const char* msg, const char* reason) { HTTPClient http; String url = String(host_api) + "?action=report_change&status=" + String(status) + "&msg=" + String(msg) + "&reason=" + String(reason); http.begin(url); int httpCode = http.GET(); if (httpCode > 0) { Serial.printf("[REPORT] %s, HTTP: %d\n", status, httpCode); } else { Serial.printf("[REPORT] Failed: %s\n", http.errorToString(httpCode).c_str()); } http.end(); } void processCommand(String jsonPayload) { JsonDocument doc; DeserializationError error = deserializeJson(doc, jsonPayload); if (error) { Serial.print("deserializeJson() failed: "); Serial.println(error.c_str()); return; } const char* command = doc["command"]; if (strcmp(command, "SCAN") == 0) { Serial.println("Command: SCAN"); scanAndSendResults(); } else if (strcmp(command, "CHANGE") == 0) { String new_ssidStr = doc["ssid"].as(); String new_passStr = doc["password"].as(); const char* new_ssid = new_ssidStr.c_str(); const char* new_pass = new_passStr.c_str(); Serial.println("Command: CHANGE"); Serial.print("Target: "); Serial.println(new_ssid); // Simpan kredensial lama untuk fallback String old_ssidStr = savedSSID; String old_passStr = savedPass; // Fallback ke WiFi.SSID() jika NVS kosong dan WiFi masih connect if (old_ssidStr.length() == 0 && WiFi.status() == WL_CONNECTED) { old_ssidStr = WiFi.SSID(); old_passStr = WiFi.psk(); } Serial.print("Backup: "); Serial.println(old_ssidStr); reportChangeStatus("processing", "Mencoba_menghubungkan_ke_WiFi_baru"); // Beri waktu server mencatat status 'processing' delay(1000); // 1. Matikan portal secara penuh untuk hilangkan intervensi WiFiManager stopPortal(); delay(500); // 2. Clear credentials lamanya (secara memory module) dan ubah ke mode Station WiFi.disconnect(true); delay(500); WiFi.mode(WIFI_STA); delay(100); // 3. PRE-CONNECTION SCAN: Cek apakah SSID target ada dan ukur RSSI // Ini untuk membedakan "sinyal lemah" vs "timeout" vs "SSID tidak ada" int targetRssi = -100; // Default: sangat lemah bool ssidFoundInScan = false; Serial.println("[PRE-SCAN] Scanning untuk cek RSSI target..."); int scanCount = WiFi.scanNetworks(false, false, false, 300); for (int i = 0; i < scanCount; i++) { if (WiFi.SSID(i) == new_ssidStr) { ssidFoundInScan = true; targetRssi = WiFi.RSSI(i); Serial.printf("[PRE-SCAN] SSID '%s' ditemukan, RSSI: %d dBm\n", new_ssid, targetRssi); break; } } WiFi.scanDelete(); if (!ssidFoundInScan) { Serial.printf("[PRE-SCAN] SSID '%s' TIDAK ditemukan di scan!\n", new_ssid); } // Tentukan apakah sinyal lemah (threshold: -75 dBm) bool isWeakSignal = (targetRssi < -75); if (isWeakSignal) { Serial.printf("[PRE-SCAN] PERINGATAN: Sinyal lemah (%d dBm < -75 dBm)\n", targetRssi); } // 4. Mulai koneksi ke kredensial baru // Menggunakan WiFi Event Handler + RSSI data untuk deteksi akurat bool connected = false; String detectedReason = ""; // Reset event flags sebelum mulai koneksi wifiAuthFailed = false; wifiHandshakeTimeout = false; wifiSsidNotFound = false; WiFi.begin(new_ssid, new_pass); int attempts = 0; // Max 15 detik (30 * 500ms) while (WiFi.status() != WL_CONNECTED && attempts < 30) { delay(500); Serial.print("."); attempts++; // CEK EVENT FLAGS (diset oleh WiFi event handler - RELIABLE!) if (attempts >= 2) { // PASTI password salah (reason 202) if (wifiAuthFailed) { detectedReason = "wrong_password"; Serial.println("\n[EVENT-DETECT] Password salah terdeteksi via AUTH_FAIL!"); break; } // SSID tidak ditemukan if (wifiSsidNotFound) { // Jika sebelumnya ditemukan di scan tapi sekarang tidak → sinyal terlalu lemah/intermittent if (ssidFoundInScan && isWeakSignal) { detectedReason = "weak_signal"; Serial.println("\n[EVENT-DETECT] SSID hilang + sinyal lemah → weak_signal!"); } else { detectedReason = "no_ssid"; Serial.println("\n[EVENT-DETECT] SSID tidak ditemukan!"); } break; } // HANDSHAKE TIMEOUT (reason 15/204) — AMBIGU! // Jika sinyal lemah → sinyal terlalu lemah (paket handshake hilang) // Jika sinyal kuat → kemungkinan besar password salah if (wifiHandshakeTimeout) { if (isWeakSignal) { detectedReason = "weak_signal"; Serial.printf("\n[EVENT-DETECT] Handshake timeout + sinyal lemah (%d dBm) → weak_signal!\n", targetRssi); } else { detectedReason = "wrong_password"; Serial.printf("\n[EVENT-DETECT] Handshake timeout + sinyal OK (%d dBm) → wrong_password!\n", targetRssi); } break; } } } Serial.println(); // Jika loop habis tanpa event → timeout, tapi bisa jadi sinyal lemah if (WiFi.status() == WL_CONNECTED) { connected = true; } else if (detectedReason.length() == 0) { // Tidak ada event terdeteksi, timed out if (isWeakSignal) { detectedReason = "weak_signal"; Serial.printf("[TIMEOUT] Timed out + sinyal lemah (%d dBm) → weak_signal\n", targetRssi); } else if (!ssidFoundInScan) { detectedReason = "no_ssid"; Serial.println("[TIMEOUT] Timed out + SSID tidak ditemukan di scan → no_ssid"); } else { detectedReason = "timeout"; Serial.println("[TIMEOUT] Timed out dengan sinyal OK → timeout murni"); } } if (WiFi.status() == WL_CONNECTED) { Serial.println("Berhasil terhubung ke WiFi baru!"); // Simpan kredensial baru ke NVS savedSSID = new_ssidStr; savedPass = new_passStr; saveCredentials(savedSSID, savedPass); // Lapor sukses reportChangeStatus("success", "Berhasil_terhubung"); // PENTING: Beri jeda agar report HTTP terselesaikan delay(3000); ESP.restart(); } else { Serial.println("GAGAL! Kembali ke WiFi lama..."); // Gunakan alasan yang sudah ditangkap String failReason = detectedReason.length() > 0 ? detectedReason : "timeout"; WiFi.disconnect(); delay(500); WiFi.begin(old_ssidStr.c_str(), old_passStr.c_str()); int back = 0; // Tunggu 15 detik untuk fallback ke WiFi lama while (WiFi.status() != WL_CONNECTED && back < 30) { delay(500); Serial.print("<"); back++; } Serial.println(); if (WiFi.status() == WL_CONNECTED) { Serial.println("Kembali ke WiFi lama berhasil!"); saveCredentials(old_ssidStr, old_passStr); reportChangeStatus("failed", "Gagal_terhubung._Kembali_ke_WiFi_sebelumnya.", (failReason + "_fallback_ok").c_str()); } else { Serial.println("Gagal reconnect. Loop akan handle AP mode."); reportChangeStatus("failed", "Gagal_terhubung._Hotspot_aktif.", (failReason + "_fallback_fail").c_str()); // AP akan jalan di loop() } } } } void scanAndSendResults() { int n = WiFi.scanNetworks(); Serial.println("Scan done"); JsonDocument doc; JsonArray networks = doc["networks"].to(); if (n == 0) { Serial.println("No networks found"); } else { for (int i = 0; i < n; ++i) { JsonObject network = networks.add(); network["ssid"] = WiFi.SSID(i); network["signal"] = WiFi.RSSI(i); network["locked"] = (WiFi.encryptionType(i) != WIFI_AUTH_OPEN); delay(10); } } String jsonOutput; serializeJson(doc, jsonOutput); HTTPClient http; String url = String(host_api) + "?action=esp_post_scan"; http.begin(url); http.addHeader("Content-Type", "application/json"); int httpCode = http.POST(jsonOutput); if (httpCode > 0) { Serial.printf("[SCAN] POST: %d\n", httpCode); } else { Serial.printf("[SCAN] Failed: %s\n", http.errorToString(httpCode).c_str()); } http.end(); }