956 lines
36 KiB
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;">● 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();
|
|
}
|