adding alert < 2025

This commit is contained in:
2025-11-24 08:32:53 +08:00
commit 94e3319c59
11 changed files with 1493 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.pio
.vscode/.browse.c_cpp.db*
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch

9
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,9 @@
{
"recommendations": [
"pioarduino.pioarduino-ide",
"platformio.platformio-ide"
],
"unwantedRecommendations": [
"ms-vscode.cpptools-extension-pack"
]
}

BIN
data/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

212
data/index.html Normal file
View File

@@ -0,0 +1,212 @@
<!-- data/index.html -->
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Bel Sekolah</title>
<style>
/* Inline Tailwind CSS for offline compatibility */
.bg-gradient-to-b { background-image: linear-gradient(to bottom, var(--tw-gradient-stops)); }
.from-slate-50 { --tw-gradient-from: #f8fafc; --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to, rgba(248, 250, 252, 0)); }
.to-white { --tw-gradient-to: #ffffff; }
.min-h-screen { min-height: 100vh; }
.font-sans { font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif; }
.text-slate-800 { --tw-text-opacity: 1; color: rgb(30 41 59 / var(--tw-text-opacity)); }
.max-w-2xl { max-width: 42rem; }
.mx-auto { margin-left: auto; margin-right: auto; }
.p-6 { padding: 1.5rem; }
.text-center { text-align: center; }
.mb-6 { margin-bottom: 1.5rem; }
.text-5xl { font-size: 3rem; line-height: 1; }
.text-3xl { font-size: 1.875rem; line-height: 2.25rem; }
.font-bold { font-weight: 700; }
.text-blue-600 { --tw-text-opacity: 1; color: rgb(37 99 235 / var(--tw-text-opacity)); }
.text-sm { font-size: 0.875rem; line-height: 1.25rem; }
.text-slate-600 { --tw-text-opacity: 1; color: rgb(71 85 105 / var(--tw-text-opacity)); }
.mt-1 { margin-top: 0.25rem; }
.bg-white { --tw-bg-opacity: 1; background-color: rgb(255 255 255 / var(--tw-bg-opacity)); }
.shadow { --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-width 0px), var(--tw-ring-shadow), var(--tw-shadow); }
.rounded-xl { border-radius: 0.75rem; }
.mb-4 { margin-bottom: 1rem; }
.text-lg { font-size: 1.125rem; line-height: 1.75rem; }
.mb-1 { margin-bottom: 0.25rem; }
.text-5xl { font-size: 3rem; line-height: 1; }
.font-extrabold { font-weight: 800; }
.tracking-wider { letter-spacing: 0.05em; }
.text-slate-500 { --tw-text-opacity: 1; color: rgb(100 116 139 / var(--tw-text-opacity)); }
.mt-2 { margin-top: 0.5rem; }
.flex { display: flex; }
.items-center { align-items: center; }
.justify-center { justify-content: center; }
.gap-4 { gap: 1rem; }
.my-3 { margin-top: 0.75rem; margin-bottom: 0.75rem; }
.w-3 { width: 0.75rem; }
.h-3 { height: 0.75rem; }
.rounded-full { border-radius: 9999px; }
.bg-gray-300 { --tw-bg-opacity: 1; background-color: rgb(209 213 219 / var(--tw-bg-opacity)); }
.text-slate-700 { --tw-text-opacity: 1; color: rgb(51 65 85 / var(--tw-text-opacity)); }
.grid { display: grid; }
.grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
.sm\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.gap-3 { gap: 0.75rem; }
.mt-6 { margin-top: 1.5rem; }
.max-w-md { max-width: 28rem; }
.bg-slate-50 { --tw-bg-opacity: 1; background-color: rgb(248 250 252 / var(--tw-bg-opacity)); }
.p-3 { padding: 0.75rem; }
.rounded { border-radius: 0.25rem; }
.h-40 { height: 10rem; }
.overflow-auto { overflow: auto; }
.text-xs { font-size: 0.75rem; line-height: 1rem; }
.text-slate-500 { --tw-text-opacity: 1; color: rgb(100 116 139 / var(--tw-text-opacity)); }
.mt-6 { margin-top: 1.5rem; }
/* Button styles */
.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); }
/* Responsive */
@media (max-width: 640px) {
.btn { padding: 0.45rem 0.7rem; font-size: 0.9rem; }
}
</style>
</head>
<body class="bg-gradient-to-b from-slate-50 to-white min-h-screen font-sans text-slate-800">
<div class="max-w-2xl mx-auto p-6">
<header class="text-center mb-6">
<div class="text-5xl">🔔</div>
<h1 class="text-3xl font-bold text-blue-600">Bel Sekolah</h1>
<div id="schoolName" class="text-sm text-slate-600 mt-1">SMA Negeri</div>
</header>
<main class="bg-white shadow rounded-xl p-6">
<div class="text-center mb-4">
<div id="hari" class="text-lg text-slate-600 mb-1"></div>
<div id="clock" class="text-5xl font-extrabold tracking-wider">--.--.--</div>
<div id="tanggal" class="text-slate-500 mt-2"></div>
</div>
<div class="flex items-center justify-center gap-4 my-3">
<div id="statusDot" class="w-3 h-3 rounded-full bg-gray-300"></div>
<div id="statusText" class="text-slate-700">Memuat...</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3 mt-6 max-w-md mx-auto">
<button id="btnTest" class="btn btn-green">Tes Bel</button>
<button id="btnStop" class="btn btn-red">Stop</button>
<a href="/setting.html" class="btn btn-blue">Pengaturan</a>
</div>
<div id="log" class="mt-6 bg-slate-50 p-3 rounded h-40 overflow-auto text-sm text-slate-700"></div>
</main>
<footer class="text-center text-slate-500 text-xs mt-6">
© Wartana 2025 — Bel Sekolah Otomatis
</footer>
</div>
<script>
// WebSocket connection (uses same host)
const protocol = (location.protocol === 'https:') ? 'wss://' : 'ws://';
const ws = new WebSocket(protocol + location.host + '/ws');
const hariEl = document.getElementById('hari');
const clockEl = document.getElementById('clock');
const tanggalEl = document.getElementById('tanggal');
const statusDot = document.getElementById('statusDot');
const statusText = document.getElementById('statusText');
const schoolEl = document.getElementById('schoolName');
const logEl = document.getElementById('log');
function appendLog(s) {
const t = document.createElement('div');
t.textContent = (new Date()).toLocaleTimeString() + " — " + s;
logEl.prepend(t);
}
ws.onopen = () => {
appendLog("🔌 WebSocket tersambung.");
};
ws.onclose = () => {
appendLog("⚠️ WebSocket terputus.");
statusDot.style.background = 'gray';
statusText.textContent = 'Terputus';
};
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
if (msg.type === 'time') {
const h = String(msg.jam).padStart(2,'0');
const m = String(msg.menit).padStart(2,'0');
const s = String(msg.detik).padStart(2,'0');
clockEl.textContent = `${h}.${m}.${s}`;
const weekday = ["Minggu","Senin","Selasa","Rabu","Kamis","Jumat","Sabtu"];
hariEl.textContent = weekday[msg.weekday || new Date(msg.epoch*1000).getDay()];
const monthNames = ["Januari","Februari","Maret","April","Mei","Juni","Juli","Agustus","September","Oktober","November","Desember"];
tanggalEl.textContent = `${msg.hari} ${monthNames[(msg.bulan||(new Date(msg.epoch*1000)).getMonth())-1]} ${msg.tahun}`;
} else if (msg.type === 'status') {
if (msg.playing) {
statusDot.style.background = 'green';
statusText.textContent = `Bel berbunyi (Track ${msg.track})`;
} else {
statusDot.style.background = 'gray';
statusText.textContent = 'Bel siaga';
}
if (msg.schoolName) schoolEl.textContent = msg.schoolName;
// Update clients info
const clientsInfoEl = document.getElementById('clientsInfo');
if (msg.clientList && msg.clientList.length > 0) {
let info = '';
msg.clientList.forEach((client, index) => {
if (index > 0) info += ', ';
info += `${client.mac}: ${client.percentage}%`;
});
clientsInfoEl.textContent = info;
} else {
clientsInfoEl.textContent = '';
}
} else if (msg.type === 'log') {
appendLog(msg.msg);
}
} catch(e) {
console.error("ws onmessage parse err", e);
}
};
// Buttons
document.getElementById('btnTest').addEventListener('click', async () => {
// call protected API -> will prompt basic auth
// test uses track 1000 (same as button click)
try {
await fetch('/api/play?track=1000');
} catch(e) { console.error(e); }
});
document.getElementById('btnStop').addEventListener('click', async () => {
try {
await fetch('/api/stop');
} catch(e) { console.error(e); }
});
</script>
</body>
</html>

422
data/setting.html Normal file
View File

@@ -0,0 +1,422 @@
<!-- data/setting.html -->
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Pengaturan — Bel Sekolah</title>
<style>
/* Inline Tailwind CSS for offline compatibility */
.bg-slate-50 { --tw-bg-opacity: 1; background-color: rgb(248 250 252 / var(--tw-bg-opacity)); }
.min-h-screen { min-height: 100vh; }
.p-6 { padding: 1.5rem; }
.max-w-3xl { max-width: 48rem; }
.mx-auto { margin-left: auto; margin-right: auto; }
.bg-white { --tw-bg-opacity: 1; background-color: rgb(255 255 255 / var(--tw-bg-opacity)); }
.p-4 { padding: 1rem; }
.rounded-xl { border-radius: 0.75rem; }
.shadow { --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-width 0px), var(--tw-ring-shadow), var(--tw-shadow); }
.mb-6 { margin-bottom: 1.5rem; }
.text-2xl { font-size: 1.5rem; line-height: 2rem; }
.font-bold { font-weight: 700; }
.text-blue-600 { --tw-text-opacity: 1; color: rgb(37 99 235 / var(--tw-text-opacity)); }
.text-center { text-align: center; }
.mb-4 { margin-bottom: 1rem; }
.block { display: block; }
.text-sm { font-size: 0.875rem; line-height: 1.25rem; }
.font-medium { font-weight: 500; }
.text-slate-700 { --tw-text-opacity: 1; color: rgb(51 65 85 / var(--tw-text-opacity)); }
.mb-2 { margin-bottom: 0.5rem; }
.w-full { width: 100%; }
.border { border-width: 1px; border-style: solid; border-color: rgb(203 213 225); }
.rounded { border-radius: 0.25rem; }
.px-3 { padding-left: 0.75rem; padding-right: 0.75rem; }
.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
.mb-3 { margin-bottom: 0.75rem; }
.hidden { display: none; }
.text-red-600 { --tw-text-opacity: 1; color: rgb(220 38 38 / var(--tw-text-opacity)); }
.text-slate-600 { --tw-text-opacity: 1; color: rgb(71 85 105 / var(--tw-text-opacity)); }
.font-semibold { font-weight: 600; }
.w-12 { width: 3rem; }
.bg-slate-100 { --tw-bg-opacity: 1; background-color: rgb(241 245 249 / var(--tw-bg-opacity)); }
.p-2 { padding: 0.5rem; }
.text-slate-400 { --tw-text-opacity: 1; color: rgb(148 163 184 / var(--tw-text-opacity)); }
.flex { display: flex; }
.gap-2 { gap: 0.5rem; }
.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-green { background: #059669; }
.btn.btn-blue { background: #2563EB; }
.btn.btn-red { background: #DC2626; }
.btn.btn-gray { background: #6B7280; }
.btn.btn-purple { background: #7C3AED; }
.btn:hover { filter: brightness(0.95); }
.flex-1 { flex: 1 1 0%; }
.md\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid { display: grid; }
.gap-3 { gap: 0.75rem; }
.text-xs { font-size: 0.75rem; line-height: 1rem; }
.text-slate-500 { --tw-text-opacity: 1; color: rgb(100 116 139 / var(--tw-text-opacity)); }
.overflow-x-auto { overflow-x: auto; }
.whitespace-nowrap { white-space: nowrap; }
/* Responsive */
@media (max-width: 768px) {
.md\:grid-cols-2 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
}
</style>
</head>
<body class="bg-slate-50 min-h-screen p-6">
<div class="max-w-3xl mx-auto">
<!-- Login Form -->
<div id="loginForm" class="bg-white p-6 rounded-xl shadow mb-6">
<h1 class="text-2xl font-bold text-blue-600 text-center mb-4">🔐 Login Admin</h1>
<form onsubmit="login(event)">
<div class="mb-4">
<label class="block text-sm font-medium text-slate-700 mb-2">Username</label>
<input id="loginUser" type="text" class="w-full border rounded px-3 py-2" placeholder="Masukkan username" required>
</div>
<div class="mb-6">
<label class="block text-sm font-medium text-slate-700 mb-2">Password</label>
<input id="loginPass" type="password" class="w-full border rounded px-3 py-2" placeholder="Masukkan password" required>
</div>
<button type="submit" class="w-full btn btn-blue">Masuk</button>
</form>
<div id="loginError" class="mt-4 text-red-600 text-sm text-center hidden"></div>
</div>
<!-- Settings Content (hidden initially) -->
<div id="settingsContent" class="hidden">
<header class="text-center mb-6">
<h1 class="text-2xl font-bold text-blue-600">⚙️ Pengaturan Bel Sekolah</h1>
<p class="text-sm text-slate-600">Kelola jadwal, nama sekolah, dan kredensial admin</p>
</header>
<section class="bg-white p-4 rounded-xl shadow mb-6">
<h2 class="font-semibold mb-2">📋 Jadwal (maks 20)</h2>
<div class="overflow-x-auto">
<table class="w-full text-sm whitespace-nowrap" id="tbl">
<thead class="bg-slate-100">
<tr><th class="p-2">#</th><th class="p-2">Aktif</th><th class="p-2">Jam</th><th class="p-2">Menit</th><th class="p-2 w-12">Track</th><th class="p-2">Deskripsi</th><th class="p-2">Aksi</th></tr>
</thead>
<tbody id="body"></tbody>
</table>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<button onclick="tambah()" class="btn btn-green" title="Tambah"></button>
<button onclick="simpan()" class="btn btn-blue" title="Simpan Semua">💾</button>
<button onclick="stopBel()" class="btn btn-red" title="Stop"></button>
<a href="/" class="btn btn-gray" title="Kembali">🏠</a>
</div>
</section>
<section class="bg-white p-4 rounded-xl shadow mb-6">
<h2 class="font-semibold mb-2">🏫 Nama Sekolah</h2>
<div class="flex gap-2">
<input id="schoolName" class="flex-1 border rounded px-3 py-2" placeholder="Nama sekolah">
<button onclick="saveName()" class="btn btn-blue">💾 Simpan</button>
</div>
</section>
<section class="bg-white p-4 rounded-xl shadow mb-6">
<h2 class="font-semibold mb-2">📅 Pengaturan Hari Minggu</h2>
<div class="flex items-center gap-2">
<input type="checkbox" id="skipSunday" class="w-4 h-4">
<label for="skipSunday" class="text-sm text-slate-700">Jangan jalankan jadwal pada hari Minggu</label>
<button onclick="saveSkipSunday()" class="btn btn-blue">💾 Simpan</button>
</div>
</section>
<section class="bg-white p-4 rounded-xl shadow mb-6">
<h2 class="font-semibold mb-2">🕒 Sinkronisasi Waktu</h2>
<p class="text-sm text-slate-600 mb-3">Sinkronisasi waktu RTC dengan waktu browser</p>
<button onclick="syncTime()" class="btn btn-purple">🕒 Sync Waktu</button>
</section>
<section class="bg-white p-4 rounded-xl shadow mb-6">
<h2 class="font-semibold mb-2">🔐 Ganti Login Admin</h2>
<div class="grid md:grid-cols-2 gap-3 mb-3">
<input id="userInput" type="text" class="border rounded px-3 py-2" placeholder="Username baru">
<input id="passInput" type="password" class="border rounded px-3 py-2" placeholder="Password baru">
</div>
<div class="flex gap-2">
<button onclick="changeAdmin()" class="btn btn-purple">💾 Simpan Login Baru</button>
<a id="whatsappLink" href="https://wa.me/628113936644?text=Perlu%20Bantuan%3F" target="_blank" class="btn btn-green">💬 Perlu Bantuan?</a>
</div>
</section>
<section class="bg-white p-4 rounded-xl shadow mb-6">
<h2 class="font-semibold mb-2">💾 Backup & Restore</h2>
<p class="text-sm text-slate-600 mb-3">Cadangkan atau pulihkan pengaturan dan jadwal</p>
<div class="flex gap-2">
<button onclick="backup()" class="btn btn-blue">⬇️ Backup</button>
<input type="file" id="restoreFile" accept=".json" class="hidden">
<button onclick="restore()" class="btn btn-blue">⬆️ Restore</button>
</div>
</section>
<footer class="text-center text-xs text-slate-500">© Wartana 2025 — Bel Sekolah Otomatis</footer>
</div>
</div>
<script>
let jadwal = [];
let isLoggedIn = false;
async function login(event) {
event.preventDefault();
const user = document.getElementById('loginUser').value.trim();
const pass = document.getElementById('loginPass').value.trim();
if (!user || !pass) {
showLoginError('Masukkan username dan password');
return;
}
try {
const response = await fetch('/api/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user, pass })
});
const result = await response.json();
if (result.valid) {
isLoggedIn = true;
document.getElementById('loginForm').classList.add('hidden');
document.getElementById('settingsContent').classList.remove('hidden');
loadJadwal();
loadConfig();
} else {
showLoginError('Username atau password salah');
}
} catch (error) {
showLoginError('Terjadi kesalahan. Coba lagi.');
}
}
function showLoginError(message) {
const errorEl = document.getElementById('loginError');
errorEl.textContent = message;
errorEl.classList.remove('hidden');
}
async function loadJadwal() {
if (!isLoggedIn) return;
const r = await fetch('/api/jadwal');
const d = await r.json();
jadwal = d || [];
render();
}
function render(){
if (!isLoggedIn) return;
const body = document.getElementById('body');
body.innerHTML = '';
if (!jadwal.length) {
body.innerHTML = `<tr><td colspan="7" class="p-3 text-center text-slate-400">Belum ada jadwal</td></tr>`;
return;
}
jadwal.forEach((j,i) => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td class="p-2 text-center">${i+1}</td>
<td class="p-2 text-center"><input type="checkbox" ${j.enabled !== false ? 'checked' : ''}></td>
<td class="p-2"><input type="number" min="0" max="23" value="${j.jam}"></td>
<td class="p-2"><input type="number" min="0" max="59" value="${j.menit}"></td>
<td class="p-2"><input type="number" min="1" value="${j.track || j.trackStart || 1}"></td>
<td class="p-2"><input type="text" value="${j.desc || ''}"></td>
<td class="p-2 text-center">
<button onclick="preview(${j.track || j.trackStart || 1})" class="btn btn-green small">▶</button>
<button onclick="del(${i})" class="btn btn-red small">❌</button>
</td>`;
body.appendChild(tr);
});
}
function tambah(){
if (!isLoggedIn) return;
if (jadwal.length >= 20) return alert('Maksimal 20 jadwal');
jadwal.push({jam:7, menit:0, track:1, desc:'Bel Masuk', enabled: true});
render();
}
function del(i){
if (!isLoggedIn) return;
jadwal.splice(i,1);
render();
}
async function simpan(){
if (!isLoggedIn) return;
// gather from table inputs
const rows = document.querySelectorAll('#body tr');
const arr = [];
rows.forEach(r => {
const inputs = r.querySelectorAll('input');
const checkbox = r.querySelector('input[type="checkbox"]');
if (inputs.length >= 5) { // checkbox + 4 inputs
const trackVal = parseInt(inputs[3].value || 1); // inputs[3] is track
arr.push({
jam: parseInt(inputs[1].value || 0), // inputs[1] is jam
menit: parseInt(inputs[2].value || 0), // inputs[2] is menit
track: trackVal, // inputs[3] is track
desc: inputs[4].value || '', // inputs[4] is desc
enabled: checkbox ? checkbox.checked : true
});
}
});
await fetch('/api/save', {
method:'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify(arr)
});
alert('✅ Jadwal disimpan');
loadJadwal();
}
async function saveName(){
if (!isLoggedIn) return;
const name = document.getElementById('schoolName').value.trim();
if (!name) return alert('Isi nama sekolah');
await fetch('/api/config', {
method:'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({schoolName: name})
});
alert('✅ Nama sekolah disimpan');
}
async function saveSkipSunday(){
if (!isLoggedIn) return;
const skip = document.getElementById('skipSunday').checked;
await fetch('/api/config', {
method:'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({skipSunday: skip})
});
alert('✅ Pengaturan hari Minggu disimpan');
}
async function changeAdmin(){
if (!isLoggedIn) return;
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 diperbarui. Silakan login ulang.');
location.reload();
} else alert('❌ Gagal');
}
async function preview(track){
if (!isLoggedIn) return;
await fetch(`/api/play?track=${track}`);
}
async function stopBel(){
if (!isLoggedIn) return;
await fetch('/api/stop');
}
async function loadConfig(){
if (!isLoggedIn) return;
const r = await fetch('/api/config');
const d = await r.json();
document.getElementById('schoolName').value = d.schoolName || '';
document.getElementById('userInput').placeholder = d.user || 'admin';
document.getElementById('skipSunday').checked = d.skipSunday || false;
}
async function loadOTAInfo(){
if (!isLoggedIn) return;
try {
const response = await fetch('/api/ota');
const data = await response.json();
document.getElementById('otaHostname').textContent = data.hostname;
alert(`OTA Info:\nHostname: ${data.hostname}\nPassword: ${data.password}\n\nGunakan Arduino IDE atau PlatformIO untuk upload OTA.`);
} catch (error) {
alert('❌ Gagal memuat info OTA');
}
}
async function syncTime(){
if (!isLoggedIn) return;
const now = new Date();
// Use UTC time (subtract timezone offset to get UTC epoch)
const epoch = Math.floor((now.getTime() - now.getTimezoneOffset() * 60000) / 1000);
try {
const response = await fetch('/api/settime', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ epoch })
});
const result = await response.json();
if (result.ok) {
alert('✅ Waktu berhasil disinkronisasi');
} else {
alert('❌ Gagal sinkronisasi waktu');
}
} catch(e) {
console.error(e);
alert('❌ Error sinkronisasi waktu');
}
}
async function backup(){
if (!isLoggedIn) return;
try {
const response = await fetch('/api/backup');
if (!response.ok) throw new Error('Backup failed');
const data = await response.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 = 'bel-sekolah-backup.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
alert('✅ Backup berhasil diunduh');
} catch (error) {
alert('❌ Gagal backup: ' + error.message);
}
}
async function restore(){
if (!isLoggedIn) return;
const fileInput = document.getElementById('restoreFile');
fileInput.click();
fileInput.onchange = async () => {
const file = fileInput.files[0];
if (!file) return;
try {
const text = await file.text();
const cleanText = text.replace(/^\uFEFF/, ''); // remove BOM if present
const data = JSON.parse(cleanText);
const response = await fetch('/api/restore', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) throw new Error('Restore failed');
const result = await response.json();
if (result.ok) {
alert('✅ Restore berhasil. Halaman akan dimuat ulang.');
location.reload();
} else {
alert('❌ Restore gagal');
}
} catch (error) {
alert('❌ Gagal restore: ' + error.message);
}
};
}
</script>
</body>
</html>

25
data/style.css Normal file
View File

@@ -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; }
}

37
include/README Normal file
View File

@@ -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

46
lib/README Normal file
View File

@@ -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 <Foo.h>
#include <Bar.h>
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

44
platformio.ini Normal file
View File

@@ -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

682
src/main.cpp Normal file
View File

@@ -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 <Arduino.h>
#include <WiFi.h>
#include <ESPmDNS.h>
#include <LittleFS.h>
#include <RTClib.h>
#include <ESPAsyncWebServer.h>
#include <AsyncTCP.h>
#include <esp_wifi.h>
#include <tcpip_adapter.h>
#include <ArduinoOTA.h>
#include <limits.h>
#include <ArduinoJson.h>
#include <DFRobotDFPlayerMini.h>
#include <time.h>
#include <OneButton.h>
#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<String>();
if (d.containsKey("user")) adminUser = d["user"].as<String>();
if (d.containsKey("pass")) adminPass = d["pass"].as<String>();
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<JsonArray>();
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;
}
}
void saveSchedules() {
DynamicJsonDocument doc(4096);
JsonArray arr = doc.to<JsonArray>();
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<JsonArray>();
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(d, out);
req->send(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<JsonArray>();
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();
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<String>();
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<String>();
if (d.containsKey("pass")) adminPass = d["pass"].as<String>();
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<String>();
if (doc.containsKey("user")) adminUser = doc["user"].as<String>();
if (doc.containsKey("pass")) adminPass = doc["pass"].as<String>();
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<String>();
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<scheduleCount;i++){
if (!schedules[i].enabled) continue; // skip disabled schedules
if (skipSunday && now.dayOfTheWeek() == 0) continue; // skip on Sunday if enabled
DateTime target(now.year(), now.month(), now.day(), schedules[i].jam, schedules[i].menit, 0);
uint32_t tUnix = target.unixtime();
// 15 seconds earlier -> 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
}

11
test/README Normal file
View File

@@ -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