TKK_E32230273/ko.php

956 lines
36 KiB
PHP

#include <Arduino.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <WiFiManager.h>
#include <ArduinoJson.h>
#include <Preferences.h>
// ==========================================
// 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(
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
<link href="https://fonts.googleapis.com/css2?family=Google+Sans:wght@400;500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
<style>
*{box-sizing:border-box;margin:0;padding:0;}
/* ─── Base ─────────────────────────────────────── */
body{
font-family:'Google Sans',-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif!important;
background-color:#f0f2f5!important;
min-height:100vh;color:#1e293b!important;
}
/* ─── Top stripe ────────────────────────────────── */
body::before{
content:'';display:block;width:100%;height:4px;
background:linear-gradient(90deg,#4b49ac,#4b49ac,#4b49ac);
position:fixed;top:0;left:0;z-index:9999;
}
/* ─── Page wrapper ──────────────────────────────── */
.wrap{
background:#ffffff!important;
border:1px solid #e2e8f0!important;
border-radius:12px!important;
padding:0!important;margin:24px auto!important;
max-width:960px!important;
box-shadow:0 1px 3px rgba(0,0,0,0.06)!important;
overflow:hidden!important;
}
/* ─── Portal header (inside .wrap) ─────────────── */
.wrap::before{
content:'Device Configuration';
display:block;
background:#f8fafc;
padding:6px 16px;
font-size:10px;color:#64748b;font-weight:600;
text-transform:uppercase;letter-spacing:0.8px;
border-bottom:1px solid #e2e8f0;
}
.wrap::after{display:none!important;}
/* ─── Two-Column Grid ──────────────────────────── */
.portal-grid{
display:grid!important;
grid-template-columns:1fr 1fr!important;
}
.portal-left{
border-right:1px solid #e2e8f0;
background:#ffffff;
display:flex;flex-direction:column;
}
.portal-right{
background:#f8fafc;
display:flex;flex-direction:column;
padding:0;
}
/* ─── CRITICAL: Override WiFiManager default div styles ── */
/* WiFiManager sets: div{padding:5px;margin:5px 0} on ALL divs */
.portal-left div,
.portal-right div{
padding:0;
margin:0;
}
/* ─── Titles ────────────────────────────────────── */
h1,.wrap>h1{
font-weight:700!important;font-size:16px!important;
color:#0f172a!important;text-align:left!important;
margin:0!important;padding:4px 16px 2px 16px!important;
letter-spacing:-0.3px!important;
}
h2{
font-weight:700!important;font-size:15px!important;
color:#0f172a!important;text-align:left!important;
margin:0!important;padding:4px 16px 2px 16px!important;
}
h3{
font-weight:400!important;font-size:11px!important;
color:#64748b!important;text-align:left!important;
margin:0 0 4px 0!important;padding:0 16px!important;
}
/* ─── Cards / forms ─────────────────────────────── */
form,#diag{
background:transparent!important;
border:none!important;border-radius:0!important;
padding:8px 12px 12px 12px!important;margin:0!important;
box-shadow:none!important;
}
/* ─── Labels ─────────────────────────────────────── */
label{
font-size:11px!important;font-weight:600!important;
color:#475569!important;margin-bottom:6px!important;
display:block!important;
}
/* ─── Inputs ─────────────────────────────────────── */
input[type='text'],input[type='password'],
input:not([type='submit']):not([type='button']):not([type='checkbox']):not([type='radio']){
width:100%!important;padding:8px 12px!important;
border:1px solid #dadce0!important;
border-radius:8px!important;
font-size:13px!important;
background:#ffffff!important;
color:#202124!important;outline:none!important;
transition:border-color 0.2s!important;
box-sizing:border-box!important;margin-bottom:6px!important;
}
input::placeholder{color:#94a3b8!important;}
input:not([type='submit']):not([type='button']):focus{
border-color:#4b49ac!important;
box-shadow:0 0 0 3px rgba(75,73,172,0.1)!important;
}
/* ─── Button group container ─────────────────────── */
.btn-group-portal{
display:grid!important;
grid-template-columns:1fr 1fr!important;
gap:6px!important;
padding:0 12px 12px 12px!important;
}
/* ─── Buttons ─────────────────────────────────────── */
button,input[type='submit'],input[type='button'],.btn{
display:block!important;width:100%!important;
padding:7px 14px!important;
background:#4b49ac!important;
color:#fff!important;
border:none!important;
border-radius:8px!important;
font-size:13px!important;font-weight:600!important;
cursor:pointer!important;
transition:all 0.2s!important;
text-align:center!important;margin:0!important;
box-shadow:none!important;
}
button:hover,input[type='submit']:hover{
background:#3f3e91!important;
box-shadow:0 2px 8px rgba(75,73,172,0.25)!important;
}
button:active,input[type='submit']:active{
transform:translateY(1px)!important;
}
/* Remove form border/margin */
form{border:none!important;padding:8px 12px 12px 12px!important;margin:0!important;}
/* ─── Links (menu items) ─────────────────────────── */
a{
display:flex!important;align-items:center!important;
justify-content:center!important;
text-decoration:none!important;
font-size:13px!important;font-weight:600!important;
color:#1e293b!important;
padding:7px 14px!important;
margin:8px 16px!important;
border-radius:8px!important;
border:1px solid #e2e8f0!important;
background:#ffffff!important;
transition:all 0.2s!important;
}
a:hover{
background:#f1f5f9!important;
border-color:#cbd5e1!important;
color:#4b49ac!important;
}
/* ─── WiFi list rows ─────────────────────────────── */
div:has(>a[href='#p']){
display:flex!important;align-items:center!important;
justify-content:space-between!important;
padding:10px 16px!important;margin:0!important;
border-bottom:1px solid #f1f5f9!important;
background:#ffffff!important;
transition:background 0.15s!important;
}
div:has(>a[href='#p']):hover{background:#f8fafc!important;}
div:has(>a[href='#p']):last-of-type{border-bottom:none!important;}
a[href='#p']{
display:inline!important;border:none!important;
background:none!important;padding:0!important;
margin:0!important;text-align:left!important;
font-size:14px!important;font-weight:600!important;
color:#202124!important;flex:1!important;
justify-content:flex-start!important;
}
a[href='#p']:hover{
background:none!important;color:#4b49ac!important;
border:none!important;
}
.q{
font-size:11px!important;font-weight:600!important;
color:#5f6368!important;
white-space:nowrap!important;margin-left:12px!important;
background:#f1f5f9!important;padding:2px 6px!important;border-radius:4px!important;
}
div[role='img']{display:inline-flex!important;margin-left:8px!important;align-items:center!important;}
/* ─── Status messages ────────────────────────────── */
.msg,div.msg{
padding:10px 14px!important;border-radius:8px!important;
background:#f8fafc!important;
color:#202124!important;
border:1px solid #e2e8f0!important;
margin:12px 16px!important;
font-size:12px!important;font-weight:600!important;
word-wrap:break-word!important;
overflow-wrap:break-word!important;
box-sizing:border-box!important;
max-width:calc(100% - 32px)!important;
}
/* ─── Dividers ───────────────────────────────────── */
hr{
border:none!important;
height:1px!important;
background:#e2e8f0!important;
margin:6px 0!important;
}
br{display:block!important;margin:2px!important;content:''!important;}
small,.r{
font-size:11px!important;color:#5f6368!important;
}
input[type='checkbox']{accent-color:#4b49ac!important;}
/* ─── Typing Cursor ──────────────────────────────── */
.cursor{
display:inline-block!important;
width:4px!important;
height:1.1em!important;
background:linear-gradient(
to bottom,
rgba(75,73,172,0.6) 0%,
rgba(75,73,172,0.6) 55%,
rgba(0,128,0,0.5) 70%,
rgba(255,193,0,0.5) 85%,
rgba(255,0,0,0.5) 100%
)!important;
vertical-align:middle!important;
animation:cursorBlink 1.2s step-end infinite!important;
margin-left:4px!important;
margin-top:-3px!important;
border-radius:3px!important;
filter:blur(1.5px)!important;
box-shadow:0 0 6px 2px rgba(75,73,172,0.3)!important;
}
@keyframes cursorBlink{
0%,100%{opacity:1;}
50%{opacity:0;}
}
/* ─── Responsive: Mobile ────────────────────────── */
@media(max-width:768px){
.wrap{margin:0!important;border-radius:0!important;border:none!important;max-width:100%!important;box-shadow:none!important;}
.portal-grid{grid-template-columns:1fr!important;}
.portal-left{border-right:none!important;border-bottom:1px solid #e2e8f0;}
.portal-right{background:#ffffff;}
h1,.wrap>h1,h2{font-size:15px!important;padding:2px 16px 2px 16px!important;}
form{padding:8px 16px 12px 16px!important;}
div:has(>a[href='#p']){padding:10px 16px!important;}
a{margin:8px 16px!important;}
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
var wrap = document.querySelector('.wrap');
if (!wrap) return;
// ═══ Step 1: Create grid container ═══
var grid = document.createElement('div');
grid.className = 'portal-grid';
// ═══ Step 2: Build LEFT panel (info) ═══
var left = document.createElement('div');
left.className = 'portal-left';
left.innerHTML = '' +
'<div style="padding:8px 16px 0 16px;">' +
'<div style="display:flex;align-items:center;gap:10px;margin-bottom:10px;">' +
'<div style="width:40px;height:40px;background:linear-gradient(135deg,#4b49ac,#4b49ac);border-radius:10px;display:flex;align-items:center;justify-content:center;flex-shrink:0;">' +
'<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect><line x1="8" y1="21" x2="16" y2="21"></line><line x1="12" y1="17" x2="12" y2="21"></line></svg>' +
'</div>' +
'<div>' +
'<div style="font-weight:700;font-size:15px;color:#0f172a;line-height:1.2;">Jaringan Internal</div>' +
'<div style="font-size:11px;color:#64748b;font-weight:500;margin-top:1px;">Sistem Kontrol Akses</div>' +
'</div>' +
'</div>' +
'<div style="margin-bottom:8px;">' +
'<h1 id="typing-heading" style="font-size:28px!important;font-weight:600!important;color:#0f172a!important;margin:0 0 0 0!important;padding:0 0 0 0!important;letter-spacing:-0.5px!important;line-height:1.25!important;">' +
'<span id="typed-text"></span><span class="cursor"></span>' +
'</h1>' +
'</div>' +
'<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:6px;">' +
'<span style="font-size:9px;font-weight:600;background:#f0fdf4;color:#166534;padding:3px 8px;border-radius:20px;border:1px solid #bbf7d0;">&#x25CF; DIAGNOSTIC MODE</span>' +
'<span style="font-size:9px;font-weight:600;background:#f8fafc;color:#475569;padding:3px 8px;border-radius:20px;border:1px solid #e2e8f0;">IP: 192.168.4.1</span>' +
'</div>' +
'</div>' +
'<div style="padding:10px 16px;background:#fffbeb;">' +
'<div style="display:flex;align-items:flex-start;gap:8px;">' +
'<div style="flex-shrink:0;margin-top:1px;">' +
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#d97706" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>' +
'</div>' +
'<div style="flex:1;">' +
'<div style="font-size:11px;font-weight:700;color:#92400e;margin-bottom:4px;">Koneksi Utama Terputus</div>' +
'<div style="font-size:10px;color:#78350f;line-height:1.4;">Modul kehilangan koneksi ke gateway utama. Pindai dan pilih ulang SSID.</div>' +
'</div>' +
'</div>' +
'</div>' +
'<div style="padding:10px 16px;background:#f8fafc;">' +
'<div style="font-size:9px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:1px;margin-bottom:8px;">Pemulihan Sistem</div>' +
'<div style="display:flex;flex-direction:column;gap:8px;">' +
'<div style="display:flex;align-items:center;gap:8px;background:#ffffff;border:1px solid #e2e8f0;border-radius:8px;padding:6px 10px;">' +
'<div style="width:12px;height:12px;border-radius:50%;border:2px solid #cbd5e1;border-top-color:#4b49ac;animation:spin 1s linear infinite;flex-shrink:0;"></div>' +
'<style>@keyframes spin{0%{transform:rotate(0)}100%{transform:rotate(360deg)}}</style>' +
'<div style="font-size:10px;color:#334155;line-height:1.3;">Sistem siap dikonfigurasi melalui portal.</div>' +
'</div>' +
'<div style="display:flex;align-items:center;gap:8px;background:#ffffff;border:1px solid #e2e8f0;border-radius:8px;padding:6px 10px;">' +
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#4b49ac" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></svg>' +
'<div style="font-size:10px;color:#334155;line-height:1.3;">Memori NVS aman. Credentials tersimpan permanen.</div>' +
'</div>' +
'</div>' +
'</div>';
// ═══ Step 3: Build RIGHT panel (WiFiManager content) ═══
var right = document.createElement('div');
right.className = 'portal-right';
// ═══ Step 4: Move ALL existing children of .wrap into .portal-right ═══
while (wrap.firstChild) {
right.appendChild(wrap.firstChild);
}
// ═══ Step 5: Assemble grid ═══
grid.appendChild(left);
grid.appendChild(right);
wrap.appendChild(grid);
// ═══ Step 6: Inject footer into right panel ═══
var footer = document.createElement('div');
footer.style.cssText = 'text-align:center;padding:12px 16px 8px;font-size:10px;color:#94a3b8;line-height:1.5;background:#f8fafc;border-top:1px solid #e2e8f0;margin-top:auto;';
footer.innerHTML = '<strong>Identia Enterprise Network Interface</strong><br>Sistem Infrastruktur Kontrol Akses v2.5.0';
right.appendChild(footer);
// ═══ Step 7: Typing animation ═══
var textToType = "Take absolute control. Enter the 192.168.4.1 Command Center.";
var typedEl = document.getElementById('typed-text');
if (typedEl) {
var ci = 0;
typedEl.innerHTML = '';
function typeEff() {
if (ci < textToType.length) {
typedEl.innerHTML += textToType.charAt(ci);
ci++;
setTimeout(typeEff, Math.random() * 50 + 30);
}
}
setTimeout(typeEff, 400);
}
});
</script>
)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<const char*> 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>();
String new_passStr = doc["password"].as<String>();
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<JsonArray>();
if (n == 0) {
Serial.println("No networks found");
} else {
for (int i = 0; i < n; ++i) {
JsonObject network = networks.add<JsonObject>();
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();
}