-
-
- | # | Aktif | Jam | Menit | Track | Deskripsi | Aksi |
-
-
-
+
+
+
+
-
-
-
-
-
-
-
-
- 🕒 Sinkronisasi Waktu
- Sinkronisasi waktu RTC dengan waktu browser
-
-
-
-
-
-
-
-
-
+
+ // --- 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