adding alert < 2025
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal 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
9
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"pioarduino.pioarduino-ide",
|
||||
"platformio.platformio-ide"
|
||||
],
|
||||
"unwantedRecommendations": [
|
||||
"ms-vscode.cpptools-extension-pack"
|
||||
]
|
||||
}
|
||||
BIN
data/favicon.ico
Normal file
BIN
data/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
212
data/index.html
Normal file
212
data/index.html
Normal 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
422
data/setting.html
Normal 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
25
data/style.css
Normal 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
37
include/README
Normal 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
46
lib/README
Normal 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
44
platformio.ini
Normal 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
682
src/main.cpp
Normal 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
11
test/README
Normal 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
|
||||
Reference in New Issue
Block a user