commit 94e3319c59c99f1e4e291020e4ef05c4d1cc8b51 Author: wwartana Date: Mon Nov 24 08:32:53 2025 +0800 adding alert < 2025 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..89cc49c --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.pio +.vscode/.browse.c_cpp.db* +.vscode/c_cpp_properties.json +.vscode/launch.json +.vscode/ipch diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..8057bc7 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,9 @@ +{ + "recommendations": [ + "pioarduino.pioarduino-ide", + "platformio.platformio-ide" + ], + "unwantedRecommendations": [ + "ms-vscode.cpptools-extension-pack" + ] +} diff --git a/data/favicon.ico b/data/favicon.ico new file mode 100644 index 0000000..650dca0 Binary files /dev/null and b/data/favicon.ico differ diff --git a/data/index.html b/data/index.html new file mode 100644 index 0000000..7ff67d8 --- /dev/null +++ b/data/index.html @@ -0,0 +1,212 @@ + + + + + + + Bel Sekolah + + + +
+
+
🔔
+

Bel Sekolah

+
SMA Negeri
+
+ +
+
+
+
--.--.--
+
+
+ +
+
+
Memuat...
+
+ + + +
+ + + Pengaturan +
+ +
+
+ +
+ © Wartana 2025 — Bel Sekolah Otomatis +
+
+ + + + diff --git a/data/setting.html b/data/setting.html new file mode 100644 index 0000000..61f6ea7 --- /dev/null +++ b/data/setting.html @@ -0,0 +1,422 @@ + + + + + + + Pengaturan — Bel Sekolah + + + +
+ +
+

🔐 Login Admin

+
+
+ + +
+
+ + +
+ +
+ +
+ + + +
+ + + + diff --git a/data/style.css b/data/style.css new file mode 100644 index 0000000..d168b2a --- /dev/null +++ b/data/style.css @@ -0,0 +1,25 @@ +/* data/style.css */ +/* small helpers used by pages */ + +.btn { + display:inline-block; + padding:0.5rem 0.9rem; + border-radius:8px; + color:white; + font-weight:600; + text-decoration:none; + cursor:pointer; + box-shadow:0 2px 6px rgba(0,0,0,0.08); +} +.btn.small { padding:0.25rem 0.5rem; font-size:0.9rem; } +.btn.btn-blue{ background:#2563EB; } +.btn.btn-green{ background:#059669; } +.btn.btn-red{ background:#DC2626; } +.btn.btn-gray{ background:#6B7280; } +.btn.btn-purple{ background:#7C3AED; } +.btn:hover{ filter:brightness(0.95); } + +/* small responsive */ +@media (max-width:640px) { + .btn { padding:0.45rem 0.7rem; font-size:0.9rem; } +} diff --git a/include/README b/include/README new file mode 100644 index 0000000..49819c0 --- /dev/null +++ b/include/README @@ -0,0 +1,37 @@ + +This directory is intended for project header files. + +A header file is a file containing C declarations and macro definitions +to be shared between several project source files. You request the use of a +header file in your project source file (C, C++, etc) located in `src` folder +by including it, with the C preprocessing directive `#include'. + +```src/main.c + +#include "header.h" + +int main (void) +{ + ... +} +``` + +Including a header file produces the same results as copying the header file +into each source file that needs it. Such copying would be time-consuming +and error-prone. With a header file, the related declarations appear +in only one place. If they need to be changed, they can be changed in one +place, and programs that include the header file will automatically use the +new version when next recompiled. The header file eliminates the labor of +finding and changing all the copies as well as the risk that a failure to +find one copy will result in inconsistencies within a program. + +In C, the convention is to give header files names that end with `.h'. + +Read more about using header files in official GCC documentation: + +* Include Syntax +* Include Operation +* Once-Only Headers +* Computed Includes + +https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html diff --git a/lib/README b/lib/README new file mode 100644 index 0000000..9379397 --- /dev/null +++ b/lib/README @@ -0,0 +1,46 @@ + +This directory is intended for project specific (private) libraries. +PlatformIO will compile them to static libraries and link into the executable file. + +The source code of each library should be placed in a separate directory +("lib/your_library_name/[Code]"). + +For example, see the structure of the following example libraries `Foo` and `Bar`: + +|--lib +| | +| |--Bar +| | |--docs +| | |--examples +| | |--src +| | |- Bar.c +| | |- Bar.h +| | |- library.json (optional. for custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html +| | +| |--Foo +| | |- Foo.c +| | |- Foo.h +| | +| |- README --> THIS FILE +| +|- platformio.ini +|--src + |- main.c + +Example contents of `src/main.c` using Foo and Bar: +``` +#include +#include + +int main (void) +{ + ... +} + +``` + +The PlatformIO Library Dependency Finder will find automatically dependent +libraries by scanning project source files. + +More information about PlatformIO Library Dependency Finder +- https://docs.platformio.org/page/librarymanager/ldf.html diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..9e2aefd --- /dev/null +++ b/platformio.ini @@ -0,0 +1,44 @@ +[env:esp32doit-devkit-v1] +platform = espressif32 @ 6.4.0 +board = esp32doit-devkit-v1 +framework = arduino +board_build.filesystem = littlefs +monitor_speed = 115200 + +lib_deps = + me-no-dev/AsyncTCP @ ^1.1.1 + ottowinter/ESPAsyncWebServer-esphome @ ^3.4.0 + bblanchon/ArduinoJson @ ^7.4.2 + adafruit/RTClib @ ^2.1.4 + DFRobotDFPlayerMini @ ^1.0.6 + ESPmDNS + LittleFS + Wire + OneButton + +[env:esp32doit-devkit-v1-ota] +platform = espressif32 @ 6.4.0 +board = esp32doit-devkit-v1 +framework = arduino +board_build.filesystem = littlefs +monitor_speed = 115200 +upload_protocol = espota +upload_flags = + --auth=sekolah123 +; Set upload_port to your device's hostname, e.g., bel-sekolah-xxxx.local +; Example: upload_port = bel-sekolah-1234.local +; Uncomment and replace with your actual device hostname +upload_port = 192.168.4.1 +;pio run -e esp32doit-devkit-v1-ota --target uploadfs +;pio run -e esp32doit-devkit-v1-ota --target upload + +lib_deps = + me-no-dev/AsyncTCP @ ^1.1.1 + ottowinter/ESPAsyncWebServer-esphome @ ^3.4.0 + bblanchon/ArduinoJson @ ^7.4.2 + adafruit/RTClib @ ^2.1.4 + DFRobotDFPlayerMini @ ^1.0.6 + ESPmDNS + LittleFS + Wire + OneButton diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..1a0a618 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,682 @@ +// src/main.cpp +// Bel Sekolah ESP32 - final version +// Pins: +// RESET_PIN = 5 (hold >15s -> reset admin credentials) +// STATUS_LED_PIN = 6 +// RELAY_PIN = 7 (active LOW) +// DFPLAYER_RX_PIN = 15 +// DFPLAYER_TX_PIN = 16 +// BUSY_PIN = 17 (LOW = busy) + + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + + +#define STATUS_LED_PIN 2 +#define DFPLAYER_RX_PIN 16 //3 df +#define DFPLAYER_TX_PIN 17 //2 df +#define RELAY_PIN 19 +#define BUSY_PIN 23 +#define RESET_PIN 15 +#define BUZZER_PIN 4 + +#define RESET_HOLD_MS 15000UL +#define MAX_SCHEDULES 20 + +AsyncWebServer server(80); +AsyncWebSocket ws("/ws"); +HardwareSerial dfSerial(1); +DFRobotDFPlayerMini myDFPlayer; +RTC_DS3231 rtc; + +String schoolName = "SMA Negeri"; +String adminUser = "admin"; +String adminPass = "sekolah123"; +String apName; +bool skipSunday = false; + +struct Sch { uint8_t jam; uint8_t menit; uint16_t track; String desc; bool enabled; uint32_t lastExecuted; }; +Sch schedules[MAX_SCHEDULES]; +uint8_t scheduleCount = 0; + +bool relayOn = false; +unsigned long relayHoldUntil = 0; +bool isPlaying = false; +uint16_t lastPlayedTrack = 0; + +unsigned long lastNtpSync = 0; +unsigned long lastTimeWS = 0; +unsigned long lastLedToggle = 0; +unsigned long ledInterval = 2000; // default slow blink +bool ledState = false; +unsigned long lastBuzzerBeep = 0; +bool buzzerState = false; + +// Admin reset blinking effect +bool adminResetBlinking = false; +unsigned long adminResetEndTime = 0; + +// OneButton instance for RESET_PIN +OneButton button(RESET_PIN, true); + +////////////////////////////////////////////////////////////////////////// +// WiFi Channel Selection +////////////////////////////////////////////////////////////////////////// +int selectBestChannel() { + WiFi.mode(WIFI_STA); + WiFi.disconnect(); + delay(100); + + int n = WiFi.scanNetworks(); + int channelCount[14] = {0}; // channels 1-13 + + for (int i = 0; i < n; ++i) { + int ch = WiFi.channel(i); + if (ch >= 1 && ch <= 13) { + channelCount[ch]++; + } + } + + int minAP = INT_MAX; + int bestCh = 1; // default + for (int ch = 1; ch <= 13; ++ch) { + if (channelCount[ch] < minAP) { + minAP = channelCount[ch]; + bestCh = ch; + } + } + + WiFi.mode(WIFI_AP); // switch back to AP mode + return bestCh; +} + +////////////////////////////////////////////////////////////////////////// +// Helpers +////////////////////////////////////////////////////////////////////////// +void sendJsonWs(const JsonDocument &doc) { + String out; + serializeJson(doc, out); + ws.textAll(out); +} + +bool isAuthorized(AsyncWebServerRequest *req) { + if (req->authenticate(adminUser.c_str(), adminPass.c_str())) return true; + req->requestAuthentication("Bel Sekolah Admin"); + return false; +} + +////////////////////////////////////////////////////////////////////////// +// Config & schedule storage (LittleFS) +////////////////////////////////////////////////////////////////////////// +void loadConfig() { + if (!LittleFS.exists("/config.json")) { + // create default + DynamicJsonDocument d(256); + d["schoolName"] = schoolName; + d["user"] = adminUser; + d["pass"] = adminPass; + d["skipSunday"] = skipSunday; + File f = LittleFS.open("/config.json", "w"); + if (f) { serializeJson(d, f); f.close(); } + return; + } + File f = LittleFS.open("/config.json", "r"); + if (!f) return; + DynamicJsonDocument d(512); + DeserializationError err = deserializeJson(d, f); + f.close(); + if (!err) { + if (d.containsKey("schoolName")) schoolName = d["schoolName"].as(); + if (d.containsKey("user")) adminUser = d["user"].as(); + if (d.containsKey("pass")) adminPass = d["pass"].as(); + if (d.containsKey("skipSunday")) skipSunday = d["skipSunday"]; + } +} + +void saveConfig() { + DynamicJsonDocument d(512); + d["schoolName"] = schoolName; + d["user"] = adminUser; + d["pass"] = adminPass; + d["skipSunday"] = skipSunday; + File f = LittleFS.open("/config.json", "w"); + if (f) { serializeJson(d, f); f.close(); } +} + +void loadSchedules() { + if (!LittleFS.exists("/schedule.json")) { + // create empty json array + File f = LittleFS.open("/schedule.json", "w"); + if (f) { f.print("[]"); f.close(); } + scheduleCount = 0; + return; + } + File f = LittleFS.open("/schedule.json", "r"); + if (!f) { scheduleCount = 0; return; } + DynamicJsonDocument doc(4096); + DeserializationError err = deserializeJson(doc, f); + f.close(); + if (err) { scheduleCount = 0; return; } + JsonArray arr = doc.as(); + 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; + } +} + +void saveSchedules() { + DynamicJsonDocument doc(4096); + JsonArray arr = doc.to(); + 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("/schedule.json", "w"); + if (f) { serializeJson(doc, f); f.close(); } +} + +////////////////////////////////////////////////////////////////////////// +// DFPlayer +////////////////////////////////////////////////////////////////////////// +void initDFPlayer() { + dfSerial.begin(9600, SERIAL_8N1, DFPLAYER_RX_PIN, DFPLAYER_TX_PIN); + delay(200); + if (myDFPlayer.begin(dfSerial)) { + myDFPlayer.volume(25); // 0..30 + } +} + +void playTrack(uint16_t track, const char* desc) { + lastPlayedTrack = track; + myDFPlayer.playMp3Folder(track); // as requested + isPlaying = true; + // notify + DynamicJsonDocument doc(256); + doc["type"] = "log"; + doc["msg"] = String("Memainkan track ") + track + " - " + desc; + sendJsonWs(doc); +} + +////////////////////////////////////////////////////////////////////////// +// WebSocket: time & status broadcast +////////////////////////////////////////////////////////////////////////// +void sendTimeWS() { + DateTime now = rtc.now(); + DynamicJsonDocument d(256); + d["type"] = "time"; + d["epoch"] = now.unixtime(); + d["jam"] = now.hour(); + d["menit"] = now.minute(); + d["detik"] = now.second(); + d["hari"] = now.day(); + d["bulan"] = now.month(); + d["tahun"] = now.year(); + d["weekday"] = now.dayOfTheWeek(); // 0 = Sunday + sendJsonWs(d); +} + +void broadcastStatus() { + DateTime now = rtc.now(); + bool busy = (digitalRead(BUSY_PIN) == LOW); + DynamicJsonDocument d(1024); + d["type"] = "status"; + d["schoolName"] = schoolName; + d["playing"] = busy; // simplified: playing = busy status + d["relay"] = relayOn; + d["track"] = lastPlayedTrack; + d["epoch"] = now.unixtime(); + d["busy"] = busy; + + // Check if date is before Jan 1, 2025 + if (now.year() < 2025) { + d["warning"] = "Tanggal mundur! Periksa RTC. Jadwal dimatikan."; + } + + sendJsonWs(d); +} + +////////////////////////////////////////////////////////////////////////// +// Web server endpoints +////////////////////////////////////////////////////////////////////////// +void initWebServer() { + server.serveStatic("/", LittleFS, "/").setDefaultFile("index.html"); + + // settings page (no longer protected - has login form) + server.on("/setting.html", HTTP_GET, [](AsyncWebServerRequest *req){ + req->send(LittleFS, "/setting.html", "text/html"); + }); + + // get schedules + server.on("/api/jadwal", HTTP_GET, [](AsyncWebServerRequest *req){ + DynamicJsonDocument d(4096); + JsonArray arr = d.to(); + for (uint8_t i=0;isend(200, "application/json", out); + }); + + // save schedules + server.on("/api/save", HTTP_POST, [](AsyncWebServerRequest *req){}, + NULL, [](AsyncWebServerRequest *req, uint8_t *data, size_t len, size_t, size_t){ + DynamicJsonDocument d(4096); + DeserializationError err = deserializeJson(d, data, len); + if (err) { req->send(400, "application/json", "{\"ok\":false}"); return; } + JsonArray arr = d.as(); + uint8_t cnt = min((size_t)MAX_SCHEDULES, arr.size()); + scheduleCount = cnt; + for (uint8_t i=0;i(); + schedules[i].enabled = arr[i]["enabled"] | true; + } + saveSchedules(); + req->send(200, "application/json", "{\"ok\":true}"); + broadcastStatus(); + }); + + // config get (no password returned) + server.on("/api/config", HTTP_GET, [](AsyncWebServerRequest *req){ + DynamicJsonDocument d(256); + d["schoolName"] = schoolName; + d["user"] = adminUser; + d["skipSunday"] = skipSunday; + String out; serializeJson(d, out); + req->send(200, "application/json", out); + }); + + // save config + server.on("/api/config", HTTP_POST, [](AsyncWebServerRequest *req){}, + NULL, [](AsyncWebServerRequest *req, uint8_t *data, size_t len, size_t, size_t){ + DynamicJsonDocument d(512); + if (deserializeJson(d, data, len)) { req->send(400); return; } + if (d.containsKey("schoolName")) schoolName = d["schoolName"].as(); + if (d.containsKey("skipSunday")) skipSunday = d["skipSunday"]; + saveConfig(); + req->send(200, "application/json", "{\"ok\":true}"); + broadcastStatus(); + }); + + // validate login credentials + server.on("/api/validate", HTTP_POST, [](AsyncWebServerRequest *req){}, + NULL, [](AsyncWebServerRequest *req, uint8_t *data, size_t len, size_t, size_t){ + if (!data || len == 0) { req->send(400, "application/json", "{\"valid\":false}"); return; } + DynamicJsonDocument d(256); + DeserializationError err = deserializeJson(d, data, len); + if (err) { req->send(400, "application/json", "{\"valid\":false}"); return; } + String user = d["user"] | ""; + String pass = d["pass"] | ""; + bool valid = (user == adminUser && pass == adminPass); + req->send(200, "application/json", valid ? "{\"valid\":true}" : "{\"valid\":false}"); + }); + + // change admin credentials + server.on("/api/admin", HTTP_POST, [](AsyncWebServerRequest *req){}, + NULL, [](AsyncWebServerRequest *req, uint8_t *data, size_t len, size_t, size_t){ + DynamicJsonDocument d(256); + if (deserializeJson(d, data, len)) { req->send(400); return; } + if (d.containsKey("user")) adminUser = d["user"].as(); + if (d.containsKey("pass")) adminPass = d["pass"].as(); + saveConfig(); + req->send(200, "application/json", "{\"ok\":true}"); + }); + + // manual play + server.on("/api/play", HTTP_GET, [](AsyncWebServerRequest *req){ + if (!req->hasParam("track")) { req->send(400, "text/plain", "Missing track"); return; } + int t = req->getParam("track")->value().toInt(); + playTrack((uint16_t)t, "Manual"); + req->send(200, "text/plain", "OK"); + }); + + // manual stop + server.on("/api/stop", HTTP_GET, [](AsyncWebServerRequest *req){ + myDFPlayer.stop(); + isPlaying = false; + req->send(200, "text/plain", "Stopped"); + broadcastStatus(); + }); + + // OTA status + server.on("/api/ota", HTTP_GET, [](AsyncWebServerRequest *req){ + DynamicJsonDocument d(256); + d["hostname"] = apName; + d["password"] = "sekolah123"; + String out; serializeJson(d, out); + req->send(200, "application/json", out); + }); + + // backup: return config + schedules as JSON + server.on("/api/backup", HTTP_GET, [](AsyncWebServerRequest *req){ + 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; + } + String out; + serializeJson(doc, out); + req->send(200, "application/json", out); + }); + + // set time from browser + server.on("/api/settime", HTTP_POST, [](AsyncWebServerRequest *req){}, + NULL, [](AsyncWebServerRequest *req, uint8_t *data, size_t len, size_t, size_t){ + DynamicJsonDocument doc(256); + DeserializationError err = deserializeJson(doc, data, len); + if (err) { + req->send(400, "application/json", "{\"ok\":false,\"error\":\"Invalid JSON\"}"); + return; + } + if (doc.containsKey("epoch")) { + uint32_t epoch = doc["epoch"]; + DateTime dt(epoch); + rtc.adjust(dt); + DynamicJsonDocument d(256); + d["type"] = "log"; + d["msg"] = "🕒 Waktu disinkronisasi dari browser"; + sendJsonWs(d); + req->send(200, "application/json", "{\"ok\":true}"); + } else { + req->send(400, "application/json", "{\"ok\":false,\"error\":\"Missing epoch\"}"); + } + }); + + // restore: parse JSON and update config + schedules + server.on("/api/restore", HTTP_POST, [](AsyncWebServerRequest *req){}, + NULL, [](AsyncWebServerRequest *req, uint8_t *data, size_t len, size_t, size_t){ + DynamicJsonDocument doc(4096); + DeserializationError err = deserializeJson(doc, data, len); + if (err) { + req->send(400, "application/json", "{\"ok\":false,\"error\":\"Invalid JSON\"}"); + return; + } + // Update config + if (doc.containsKey("schoolName")) schoolName = doc["schoolName"].as(); + if (doc.containsKey("user")) adminUser = doc["user"].as(); + if (doc.containsKey("pass")) adminPass = doc["pass"].as(); + saveConfig(); + // Update schedules + 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(); + } + req->send(200, "application/json", "{\"ok\":true}"); + broadcastStatus(); + }); + + // 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) { + sendTimeWS(); + broadcastStatus(); + } + // Incoming messages are not used currently + }); + + server.addHandler(&ws); + server.begin(); +} + +////////////////////////////////////////////////////////////////////////// +// Reset admin via button (hold >15s) +////////////////////////////////////////////////////////////////////////// +void resetAdminToDefault() { + adminUser = "admin"; + adminPass = "sekolah123"; + saveConfig(); + // Start admin reset blinking effect for 5 seconds + adminResetBlinking = true; + adminResetEndTime = millis() + 5000UL; // 5 seconds + DynamicJsonDocument d(256); + d["type"] = "log"; + d["msg"] = "🔐 Admin reset to default: admin / sekolah123"; + sendJsonWs(d); +} + +////////////////////////////////////////////////////////////////////////// +// Setup & Loop +////////////////////////////////////////////////////////////////////////// +void setup(){ + pinMode(STATUS_LED_PIN, OUTPUT); + digitalWrite(STATUS_LED_PIN, LOW); + pinMode(RELAY_PIN, OUTPUT); + digitalWrite(RELAY_PIN, HIGH); // relay active LOW, so HIGH = off + pinMode(BUSY_PIN, INPUT); // busy pin input (LOW when busy) + pinMode(RESET_PIN, INPUT_PULLUP); + pinMode(BUZZER_PIN, OUTPUT); + digitalWrite(BUZZER_PIN, LOW); + + Serial.begin(115200); + delay(200); + + if (!LittleFS.begin(true)) { + Serial.println("[ERR] LittleFS mount failed"); + } + + loadConfig(); + loadSchedules(); + + if (!rtc.begin()) { + Serial.println("[ERR] RTC not found"); + } + + initDFPlayer(); + + // Select best WiFi channel + int bestChannel = selectBestChannel(); + Serial.printf("[INFO] Selected WiFi channel: %d\n", bestChannel); + + // AP name with 4-digit mac suffix (lowercase) + String mac = WiFi.macAddress(); mac.replace(":", ""); mac.toLowerCase(); + apName = "bel-sekolah-" + mac.substring(mac.length() - 4); + + // Setup AP mode with selected channel + WiFi.softAP(apName.c_str(), "sekolah123", bestChannel); + WiFi.softAPConfig(IPAddress(192, 168, 4, 1), IPAddress(192, 168, 4, 1), IPAddress(255, 255, 255, 0)); + + // mdns hostname same as SSID requirement + MDNS.begin(apName.c_str()); + + // Setup OTA + ArduinoOTA.setHostname(apName.c_str()); + ArduinoOTA.setPassword("sekolah123"); + ArduinoOTA.onStart([]() { + String type = (ArduinoOTA.getCommand() == U_FLASH) ? "sketch" : "filesystem"; + Serial.println("Start updating " + type); + DynamicJsonDocument d(256); + d["type"] = "log"; + d["msg"] = "🔄 Mulai update OTA: " + type; + sendJsonWs(d); + }); + ArduinoOTA.onEnd([]() { + Serial.println("\nEnd"); + DynamicJsonDocument d(256); + d["type"] = "log"; + d["msg"] = "✅ Update OTA selesai. Restarting..."; + sendJsonWs(d); + }); + ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) { + Serial.printf("Progress: %u%%\r", (progress / (total / 100))); + }); + ArduinoOTA.onError([](ota_error_t error) { + Serial.printf("Error[%u]: ", error); + String msg; + if (error == OTA_AUTH_ERROR) msg = "Auth Failed"; + else if (error == OTA_BEGIN_ERROR) msg = "Begin Failed"; + else if (error == OTA_CONNECT_ERROR) msg = "Connect Failed"; + else if (error == OTA_RECEIVE_ERROR) msg = "Receive Failed"; + else if (error == OTA_END_ERROR) msg = "End Failed"; + Serial.println(msg); + DynamicJsonDocument d(256); + d["type"] = "log"; + d["msg"] = "❌ OTA Error: " + msg; + sendJsonWs(d); + }); + ArduinoOTA.begin(); + + // Setup OneButton callbacks + button.setPressMs(15000); // Set long press to 15 seconds + button.attachClick([]() { + // Click: play track 1000 + Serial.println("[BUTTON] Click detected: playing track 1000"); + playTrack(1000, "Manual Click"); + }); + + button.attachLongPressStart([]() { + // Long press: reset admin credentials + Serial.println("[BUTTON] Long press detected: resetting admin credentials"); + resetAdminToDefault(); + }); + + + + initWebServer(); + + Serial.println("Bel Sekolah siap. Hostname: " + apName + ".local"); +} + +void loop(){ + ArduinoOTA.handle(); + + ws.cleanupClients(); + + // Handle OneButton + button.tick(); + + // every second: send time & status + if (millis() - lastTimeWS >= 1000) { + lastTimeWS = millis(); + sendTimeWS(); + broadcastStatus(); + } + + // check schedules + DateTime now = rtc.now(); + uint32_t nowUnix = now.unixtime(); + bool busy = (digitalRead(BUSY_PIN) == LOW); // busy active LOW + + // LED blinking based on busy status or admin reset + if (adminResetBlinking && millis() < adminResetEndTime) { + // Admin reset blinking: 100ms interval + ledInterval = 100; + } else if (busy) { + ledInterval = 500; // fast blink: 250ms ON, 250ms OFF + adminResetBlinking = false; // stop admin reset blinking if busy + } else { + ledInterval = 2000; // slow blink: 50ms ON, 1950ms OFF + adminResetBlinking = false; // stop admin reset blinking + } + + // Toggle LED + if (millis() - lastLedToggle >= ledInterval) { + lastLedToggle = millis(); + ledState = !ledState; + digitalWrite(STATUS_LED_PIN, ledState ? HIGH : LOW); + } + + // if busy, hold relay on for at least 15s from detection + if (busy) { + digitalWrite(RELAY_PIN, LOW); // turn ON (active LOW) + relayOn = true; + relayHoldUntil = millis() + 15000UL; + } + + // schedule loop: for each schedule, compute today's target unix + if (now.year() >= 2025) { // only run schedules if date >= 2025 + for (uint8_t i=0;i enable relay early + if (nowUnix == (tUnix - 15)) { + digitalWrite(RELAY_PIN, LOW); // ON + relayOn = true; + // ensure relay will stay on for at least 30s to allow amplifier warm-up and playback + relayHoldUntil = millis() + 30000UL; + } + + // when exact time -> play track (only once per day) + if (nowUnix == tUnix && schedules[i].lastExecuted != nowUnix) { + schedules[i].lastExecuted = nowUnix; + uint16_t track = schedules[i].track; + playTrack(track, schedules[i].desc.c_str()); + // ensure relay hold covers playback + relayOn = true; + if (relayHoldUntil < millis() + 15000UL) relayHoldUntil = millis() + 15000UL; + } + } + } + + // release relay if conditions met + if (!busy && relayOn && millis() > relayHoldUntil) { + digitalWrite(RELAY_PIN, HIGH); // OFF + relayOn = false; + } + + // Buzzer warning if date < 2025 + if (now.year() < 2025) { + if (millis() - lastBuzzerBeep >= 2000) { + lastBuzzerBeep = millis(); + buzzerState = true; + digitalWrite(BUZZER_PIN, HIGH); + } + if (buzzerState && millis() - lastBuzzerBeep >= 100) { + buzzerState = false; + digitalWrite(BUZZER_PIN, LOW); + } + } + + delay(10); // tiny yield +} diff --git a/test/README b/test/README new file mode 100644 index 0000000..9b1e87b --- /dev/null +++ b/test/README @@ -0,0 +1,11 @@ + +This directory is intended for PlatformIO Test Runner and project tests. + +Unit Testing is a software testing method by which individual units of +source code, sets of one or more MCU program modules together with associated +control data, usage procedures, and operating procedures, are tested to +determine whether they are fit for use. Unit testing finds problems early +in the development cycle. + +More information about PlatformIO Unit Testing: +- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html