feat: Implement initial ESP32 school bell system with web UI, scheduling, and DFPlayer control.

This commit is contained in:
2025-12-16 16:15:55 +08:00
parent 49fabd577c
commit 49ea405b10
2 changed files with 1140 additions and 397 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -18,11 +18,11 @@
#include <RTClib.h>
#include <ESPAsyncWebServer.h>
#include <AsyncTCP.h>
#include <esp_wifi.h>
#include <tcpip_adapter.h>
#include <ArduinoOTA.h>
#include <limits.h>
#include <esp_task_wdt.h>
#include <ArduinoJson.h>
#include <DFRobotDFPlayerMini.h>
@@ -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<JsonArray>();
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<String>();
if (doc.containsKey("user")) adminUser = doc["user"].as<String>();
if (doc.containsKey("pass")) adminPass = doc["pass"].as<String>();
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<String>();
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