diff --git a/data/setting.html b/data/setting.html index 61f6ea7..1a7c7f0 100644 --- a/data/setting.html +++ b/data/setting.html @@ -1,422 +1,1035 @@ - + - + Pengaturan — Bel Sekolah - -
- -
-

🔐 Login Admin

-
-
- - -
-
- - -
- -
- -
- -
- + + // --- API Calls --- + async function loadConfig() { + if (!isLoggedIn) return; + const r = await fetch('/api/config'); + const d = await r.json(); + if (d.schoolName) { + document.getElementById('schoolName').value = d.schoolName; + document.getElementById('sidebarTitle').textContent = d.schoolName; + document.getElementById('mobileTitle').textContent = d.schoolName; + } + document.getElementById('skipSunday').checked = d.skipSunday || false; + if (d.user) document.getElementById('userInput').placeholder = d.user; + } + + async function saveName() { + const name = document.getElementById('schoolName').value.trim(); + await fetch('/api/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ schoolName: name }) + }); + document.getElementById('sidebarTitle').textContent = name; + document.getElementById('mobileTitle').textContent = name; + alert('Nama Sekolah Disimpan'); + } + + async function saveSkipSunday() { + const skip = document.getElementById('skipSunday').checked; + await fetch('/api/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ skipSunday: skip }) + }); + alert('Pengaturan Disimpan'); + } + + async function syncTime() { + const now = new Date(); + const epoch = Math.floor((now.getTime() - now.getTimezoneOffset() * 60000) / 1000); + await fetch('/api/settime', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ epoch }) + }); + alert('✅ Waktu tersinkronisasi!'); + } + + async function changeAdmin() { + const u = document.getElementById('userInput').value.trim(); + const p = document.getElementById('passInput').value.trim(); + if (!u || !p) return alert('Isi user & pass baru'); + + const res = await fetch('/api/admin', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user: u, pass: p }) + }); + if (res.ok) { + alert('Login diganti. Login ulang.'); + location.reload(); + } else alert('Gagal'); + } + + async function preview(track) { await fetch(`/api/play?track=${track}`); } + async function stopBel() { await fetch('/api/stop'); } + + async function loadOTAInfo() { + try { + const r = await fetch('/api/ota'); + const d = await r.json(); + document.getElementById('otaInfo').innerHTML = `Host: ${d.hostname}
Pass: ${d.password}`; + } catch (e) { alert('Gagal load info'); } + } + + // --- Backup Logic --- + async function loadBackupList() { + const listDiv = document.getElementById('backupList'); + try { + const res = await fetch('/api/backup/list'); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const text = await res.text(); + let files; + try { files = JSON.parse(text); } catch (e) { files = []; } + + if (!Array.isArray(files) || files.length === 0) { + listDiv.innerHTML = '
Kosong.
'; + return; + } + + files.sort((a, b) => b.name.localeCompare(a.name)); + let html = ''; + files.forEach(f => { + const nameDisplay = f.name.replace('/backup_', '').replace('.json', '').replace('_', ' '); + html += ` +
+
+
📄
+
+
${nameDisplay}
+
${f.size || 0} b
+
+
+
+ + +
+
`; + }); + listDiv.innerHTML = html; + } catch (e) { + listDiv.innerHTML = `
Error: ${e.message}
`; + } + } + + async function saveInternalBackup() { + if (!confirm('Backup sekarang?')) return; + try { + const res = await fetch('/api/backup/save', { method: 'POST' }); + if (res.ok) { alert('✅ Backup OK'); loadBackupList(); } + else throw new Error('Failed'); + } catch (e) { alert('Gagal backup'); } + } + + async function loadInternal(fname) { + if (!confirm(`Restore dari ${fname}?`)) return; + try { + const res = await fetch('/api/backup/load', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filename: fname }) + }); + if (res.ok) { alert('✅ Restore OK. Reloading...'); location.reload(); } + } catch (e) { alert('Gagal restore'); } + } + + async function delInternal(fname) { + if (!confirm(`Hapus ${fname}?`)) return; + try { + const res = await fetch('/api/backup/delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filename: fname }) + }); + if (res.ok) loadBackupList(); + } catch (e) { alert('Gagal hapus'); } + } + + async function backup() { + try { + const res = await fetch('/api/backup/download'); + const data = await res.json(); + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = 'backup.json'; + document.body.appendChild(a); a.click(); document.body.removeChild(a); + } catch (e) { alert('Gagal download'); } + } + + async function restoreFileSelected(input) { + const file = input.files[0]; + if (!file) return; + try { + const text = await file.text(); + const data = JSON.parse(text); + const res = await fetch('/api/restore', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + if (res.ok) { alert('Restore OK!'); location.reload(); } + else throw new Error('API Error'); + } catch (e) { alert('Gagal upload: ' + e.message); } + } + - + + \ No newline at end of file diff --git a/src/main.cpp b/src/main.cpp index 530cd03..9871a60 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -18,11 +18,11 @@ #include #include #include -#include + #include #include #include -#include + #include #include @@ -408,7 +408,7 @@ void initWebServer() { }); // backup: return config + schedules as JSON - server.on("/api/backup", HTTP_GET, [](AsyncWebServerRequest *req){ + server.on("/api/backup/download", HTTP_GET, [](AsyncWebServerRequest *req){ DynamicJsonDocument doc(4096); doc["schoolName"] = schoolName; doc["user"] = adminUser; @@ -483,6 +483,142 @@ void initWebServer() { broadcastStatus(); }); + // Internal: List Backups + server.on("/api/backup/list", HTTP_GET, [](AsyncWebServerRequest *req){ + JsonDocument doc; // v7: automatic storage + JsonArray arr = doc.to(); + + Serial.println("[BACKUP] Listing files..."); + File root = LittleFS.open("/"); + if (root && root.isDirectory()) { + File file = root.openNextFile(); + while(file){ + String fname = String(file.name()); + // Handle paths with or without leading slash + if (fname.indexOf("backup_") >= 0 && fname.endsWith(".json")) { + JsonObject o = arr.createNestedObject(); + if (!fname.startsWith("/")) fname = "/" + fname; + o["name"] = fname; + o["size"] = file.size(); + Serial.printf("[BACKUP] Found: %s (%d bytes)\n", fname.c_str(), file.size()); + } + file = root.openNextFile(); + } + } else { + Serial.println("[BACKUP] Failed to open root directory"); + } + String out; + serializeJson(doc, out); + req->send(200, "application/json", out); + }); + + // Internal: Save with Timestamp + server.on("/api/backup/save", HTTP_POST, [](AsyncWebServerRequest *req){ + DateTime now = rtc.now(); + char filename[32]; + snprintf(filename, sizeof(filename), "/backup_%04d-%02d-%02d_%02d-%02d.json", + now.year(), now.month(), now.day(), now.hour(), now.minute()); + + DynamicJsonDocument doc(4096); + doc["schoolName"] = schoolName; + doc["user"] = adminUser; + doc["pass"] = adminPass; + doc["skipSunday"] = skipSunday; + JsonArray arr = doc.createNestedArray("schedules"); + for (uint8_t i = 0; i < scheduleCount; ++i) { + JsonObject o = arr.createNestedObject(); + o["jam"] = schedules[i].jam; + o["menit"] = schedules[i].menit; + o["track"] = schedules[i].track; + o["desc"] = schedules[i].desc; + o["enabled"] = schedules[i].enabled; + } + File f = LittleFS.open(filename, "w"); + if (f) { + serializeJson(doc, f); + f.close(); + DynamicJsonDocument d(256); + d["type"] = "log"; + d["msg"] = String("💾 Backup tersimpan: ") + filename; + sendJsonWs(d); + req->send(200, "application/json", "{\"ok\":true}"); + } else { + req->send(500, "application/json", "{\"ok\":false,\"error\":\"FS Error\"}"); + } + }); + + // Internal: Load Specific Backup + server.on("/api/backup/load", HTTP_POST, [](AsyncWebServerRequest *req){}, + NULL, [](AsyncWebServerRequest *req, uint8_t *data, size_t len, size_t, size_t){ + DynamicJsonDocument input(256); + if (deserializeJson(input, data, len)) { req->send(400); return; } + String filename = input["filename"] | ""; + if (!filename.startsWith("/")) filename = "/" + filename; + + if (!LittleFS.exists(filename)) { + req->send(404, "application/json", "{\"ok\":false,\"error\":\"File not found\"}"); + return; + } + File f = LittleFS.open(filename, "r"); + if (!f) { + req->send(500, "application/json", "{\"ok\":false,\"error\":\"Read Error\"}"); + return; + } + DynamicJsonDocument doc(4096); + DeserializationError err = deserializeJson(doc, f); + f.close(); + + if (err) { + req->send(500, "application/json", "{\"ok\":false,\"error\":\"JSON Error\"}"); + return; + } + + // Restore + if (doc.containsKey("schoolName")) schoolName = doc["schoolName"].as(); + if (doc.containsKey("user")) adminUser = doc["user"].as(); + if (doc.containsKey("pass")) adminPass = doc["pass"].as(); + if (doc.containsKey("skipSunday")) skipSunday = doc["skipSunday"]; + saveConfig(); + + if (doc.containsKey("schedules")) { + JsonArray arr = doc["schedules"]; + uint8_t cnt = min((size_t)MAX_SCHEDULES, arr.size()); + scheduleCount = cnt; + for (uint8_t i = 0; i < cnt; ++i) { + schedules[i].jam = arr[i]["jam"] | 0; + schedules[i].menit = arr[i]["menit"] | 0; + schedules[i].track = arr[i]["track"] | 1; + schedules[i].desc = arr[i]["desc"].isNull() ? "" : arr[i]["desc"].as(); + schedules[i].enabled = arr[i]["enabled"] | true; + } + saveSchedules(); + } + + DynamicJsonDocument d(256); + d["type"] = "log"; + d["msg"] = "♻️ Backup dimuat: " + filename; + sendJsonWs(d); + + req->send(200, "application/json", "{\"ok\":true}"); + broadcastStatus(); + }); + + // Internal: Delete Backup + server.on("/api/backup/delete", HTTP_POST, [](AsyncWebServerRequest *req){}, + NULL, [](AsyncWebServerRequest *req, uint8_t *data, size_t len, size_t, size_t){ + DynamicJsonDocument input(256); + if (deserializeJson(input, data, len)) { req->send(400); return; } + String filename = input["filename"] | ""; + if (!filename.startsWith("/")) filename = "/" + filename; + + if (LittleFS.exists(filename)) { + LittleFS.remove(filename); + req->send(200, "application/json", "{\"ok\":true}"); + } else { + req->send(404, "application/json", "{\"ok\":false,\"error\":\"File not found\"}"); + } + }); + // websocket handler: when new client connects, send time + status ws.onEvent([](AsyncWebSocket * server, AsyncWebSocketClient * client, AwsEventType type, void * arg, uint8_t * data, size_t len){ if (type == WS_EVT_CONNECT) { @@ -516,10 +652,7 @@ void resetAdminToDefault() { // Setup & Loop ////////////////////////////////////////////////////////////////////////// void setup(){ - // Initialize Task Watchdog Timer (TWDT) to prevent watchdog resets - // Disable panic mode and set longer timeout to prevent resets - esp_task_wdt_init(120, false); // 120 seconds timeout, don't panic on timeout - esp_task_wdt_add(NULL); // Add current task to watchdog + pinMode(STATUS_LED_PIN, OUTPUT); digitalWrite(STATUS_LED_PIN, LOW); @@ -639,8 +772,7 @@ void loop(){ resetAdminToDefault(); } - // Reset watchdog early in the loop - esp_task_wdt_reset(); + // every second: send time & status if (millis() - lastTimeWS >= 1000) { @@ -706,8 +838,7 @@ void loop(){ if (relayHoldUntil < millis() + 15000UL) relayHoldUntil = millis() + 15000UL; } - // Reset watchdog during schedule processing - esp_task_wdt_reset(); + } } @@ -733,8 +864,7 @@ void loop(){ digitalWrite(BUZZER_PIN, LOW); } - // Reset Task Watchdog Timer to prevent timeout - esp_task_wdt_reset(); + // Use vTaskDelay instead of delay() to allow other tasks to run vTaskDelay(pdMS_TO_TICKS(10)); // tiny yield