Anyone who has worked in a school knows the problem. The peon or a teacher rings the bell by hand, someone forgets, and the whole period schedule slips. Commercial automatic bell controllers exist, but most are expensive and use a tiny keypad where changing one timing takes ten minutes.
So in this project we build our own ESP32 school bell. You set the timings from a web page on your phone, choose how long the bell rings for each period, and forget about it. If the power goes off, the timetable is still there when it comes back. If the WiFi is down, the bell still rings on time.
Let’s see what we need.
Table of Contents
Requirements
Hardware Requirements:
- ESP32 Development Board (30-pin or 38-pin, either is fine)
- 5V Relay Module (single channel)
- Gong bell or electric school bell (AC or DC)
- DS3231 RTC Module (with CR2032 battery)
- 2 × Push buttons (one for manual ring, one for WiFi reset)
- 5V 2A power supply or USB adapter
- Jumper wires and breadboard
Software Requirements:
- Arduino IDE with ESP32 board package installed
- RTClib library by Adafruit
WiFi.h, WebServer.h and Preferences.h come with the ESP32 board package, so nothing else is needed.
Why we need a DS3231 RTC module
This is the part most tutorials skip. The ESP32 has no battery-backed clock. Normally it gets the time from the internet (NTP), but after a power cut, if your router is also off or the WiFi is down, the ESP32 wakes up not knowing what time it is. A bell that doesn’t know the time is useless.
The DS3231 is a tiny clock module with its own coin cell. It keeps correct time for years without power, and the ESP32 reads it at startup. When WiFi is available, the ESP32 quietly corrects the RTC from the internet every few hours, so it never drifts.
Component Details
ESP32
The ESP32 runs the web server, checks the timetable every second and controls the relay. It has built-in WiFi and enough flash memory to store the timetable permanently using the Preferences library. That library keeps data in non-volatile memory, so the timings survive resets and power cuts.
5V Relay Module
The ESP32 pins can only give a few milliamps at 3.3V, so they can’t drive a bell directly. The relay acts as a switch: the ESP32 sends a small signal, and the relay’s contacts switch the bell’s much higher current.
Most blue 5V relay modules are active LOW, meaning the relay turns ON when the input pin goes LOW. The code has a RELAY_ON setting, so you can flip it if your module works the opposite way.
Gong Bell
Any electric bell works here. Wire the relay contacts like a normal switch in the bell’s supply line, using the COM and NO (Normally Open) terminals. With NO, the bell stays silent when the relay is off, which is what you want.
Safety warning: If your bell runs on 220V/110V AC mains, be extremely careful. Keep all mains wiring inside an insulated enclosure, never touch the relay terminals while powered, and get help from an electrician if you are not experienced. If you are just testing, use a small 12V DC buzzer or bell first. For DC bells, add a flyback diode across the bell terminals.
Push Buttons
- Manual ring button: press it and the bell rings for 3 seconds. Useful for emergencies, assemblies or testing.
- WiFi reset button: hold it for 5 seconds and the saved WiFi details are erased. The ESP32 restarts and creates its own hotspot so you can enter new WiFi credentials.
Circuit Diagram and Connections
Connect all the required components according to the below circuit diagram and connection table.

| Component | Pin | ESP32 Pin |
|---|---|---|
| Relay module | VCC | VIN (5V) |
| Relay module | GND | GND |
| Relay module | IN | GPIO 26 |
| Manual ring button | One leg | GPIO 27 |
| Manual ring button | Other leg | GND |
| WiFi reset button | One leg | GPIO 4 |
| WiFi reset button | Other leg | GND |
| DS3231 RTC | VCC | 3.3V |
| DS3231 RTC | GND | GND |
| DS3231 RTC | SDA | GPIO 21 |
| DS3231 RTC | SCL | GPIO 22 |
The buttons don’t need external resistors because the code uses the ESP32’s internal pull-ups. Pressing a button connects the pin to GND.
The bell wiring goes through the relay: take the bell’s supply wire, cut it, and connect one end to relay COM and the other end to relay NO.
Build a Smart School Bell System with PCBWay—Custom PCBs & 3D-Printed Enclosures for Your ESP32 Project
Transform your ESP32 into a fully automated school bell system with web-based scheduling, real-time clock, and customizable ring tones. PCBWay’s integrated manufacturing services help you turn your prototype into a polished, professional device—perfect for schools, offices, or industrial facilities.
Custom PCB Manufacturing for Reliable Scheduling:
PCBWay manufactures high-quality custom PCBs that consolidate your ESP32, RTC module (DS3231), relay driver for the bell, audio amplifier, and power regulation onto a single compact board. Their PCBs feature clean signal routing for stable I²C communication with the RTC, robust power traces to handle relay switching currents, and proper isolation to prevent noise interference during bell activation. Choose their professional SMT assembly to receive a fully populated, tested board ready for web server deployment.
Durable 3D-Printed Enclosure for Wall Mounting:
Design and order a custom enclosure through PCBWay’s advanced 3D printing service. Create a sturdy, wall-mountable housing using impact-resistant ABS or heat-resistant PETG, featuring ventilation slots for audio output, a clear window for status LEDs, and secure mounting points for DIN rail or wall installation. Add internal PCB bosses, cable glands for bell wiring, and a hinged access panel for easy maintenance—all printed with precision for long-term reliability.
Why PCBWay Delivers Professional Results:
- Reliable Automation: Clean PCB design ensures accurate timekeeping and scheduled bell activation
- Durable Installation: Custom enclosures protect electronics in demanding school environments
Build Your Smart School Bell Today:
Upload your PCB design and enclosure model for instant quotes, DFM feedback, and professional manufacturing at PCBWAY.COM
How the Project Works
Here is what the system does:
- On power-up, the ESP32 reads the time from the DS3231 and loads the saved timetable and WiFi details from flash memory.
- It tries to connect to your WiFi for 15 seconds. If it connects, you open the web page using its IP address. If there is no WiFi (or no saved credentials), it starts its own hotspot called
SchoolBell-Setupand you open192.168.4.1instead. In both cases the Schoolbell dashboard page works the same. - After connecting to a wifi network you can use http://schoolbell.local to connect to Schoolbell Dashboard.
- Every second, it compares the current time with your saved bell timings. When a time matches, the relay switches on for the number of seconds you set for that period.
- The ring is non-blocking, so the web page, buttons and clock keep working while the bell is ringing.
- Timings are saved permanently when you press Save on the web page, so power cuts don’t erase anything.
Features of the web page
- Add as many bell timings as you need (up to 20) with the Add More button
- Each timing has its own ring duration in seconds, 3 seconds by default
- Choose which days the bell should work (Monday to Saturday by default)
- Ring Now button on the page for testing
- Sync time button to set the clock from your phone if there is no internet
- WiFi settings form to change the network any time
- Password protected (username
admin, passwordbell1234, change these in the code)
Installing the Library
Open Arduino IDE, go to Sketch → Include Library → Manage Libraries, search for RTClib by Adafruit and install it. Select your board under Tools → Board → ESP32 Dev Module.
Code to Upload using Arduino IDE
Change TZ_INFO if you are not in India. "IST-5:30" is India time. For example, use "GMT0BST,M3.5.0/1,M10.5.0" for the UK. or find your’s : here
/*
ESP32 School Bell with Web Server
- Multiple bell timings, each with its own duration
- Timings saved in flash (survive power cuts)
- DS3231 RTC keeps time without WiFi
- Hold reset button 5 sec to clear WiFi credentials
- Manual ring push button
- Open http://schoolbell.local after connecting to WiFi
Website: circuitschools.com
*/
#include <WiFi.h>
#include <WebServer.h>
#include <ESPmDNS.h>
#include <Preferences.h>
#include <Wire.h>
#include <RTClib.h>
#include <time.h>
#include "esp_sntp.h"
// ---------- Pin settings ----------
#define RELAY_PIN 26
#define RELAY_ON LOW // change to HIGH if your relay is active HIGH
#define RELAY_OFF HIGH
#define MANUAL_BTN 27
#define RESET_BTN 4
#define STATUS_LED 2
// ---------- User settings ----------
const char* AP_SSID = "SchoolBell-Setup";
const char* AP_PASS = "12345678";
const char* WEB_USER = "admin";
const char* WEB_PASS = "bell1234";
const char* MDNS_NAME = "schoolbell"; // opens as http://schoolbell.local
const char* TZ_INFO = "IST-5:30"; // time zone
const uint8_t DEFAULT_DURATION = 3; // seconds
const uint8_t MAX_ENTRIES = 20;
const unsigned long RESET_HOLD_MS = 5000;
// ---------- Data ----------
struct BellEntry {
uint16_t minutes; // minutes since midnight
uint8_t duration; // seconds
};
BellEntry schedule[MAX_ENTRIES];
uint8_t entryCount = 0;
uint8_t daysMask = 0x7E; // bit0 = Sunday ... bit6 = Saturday (Mon-Sat default)
Preferences prefs;
WebServer server(80);
RTC_DS3231 rtc;
bool rtcOk = false;
bool mdnsStarted = false;
bool bellOn = false;
unsigned long bellOffAt = 0;
// ---------- Web page ----------
const char PAGE[] PROGMEM = R"rawliteral(
<!DOCTYPE html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ESP32 School Bell</title>
<style>
body{font-family:system-ui,sans-serif;margin:0;background:#f2f4f8;color:#1c2333}
header{background:#1f3a93;color:#fff;padding:16px;text-align:center}
header h1{margin:0;font-size:20px}
#clock{font-size:15px;margin-top:6px;opacity:.9}
main{max-width:640px;margin:auto;padding:12px}
.card{background:#fff;border-radius:10px;padding:14px;margin-bottom:14px;box-shadow:0 1px 4px #0002}
h2{font-size:16px;margin:0 0 10px}
.row{display:flex;gap:8px;margin-bottom:8px;align-items:center}
.row input[type=time]{flex:1}
.row input[type=number]{width:70px}
input{padding:8px;border:1px solid #c5cbd8;border-radius:6px;font-size:15px;max-width:100%;box-sizing:border-box}
button{padding:10px 14px;border:0;border-radius:6px;font-size:15px;cursor:pointer;background:#1f3a93;color:#fff}
button.alt{background:#5b6b8c}
button.del{background:#d64545;padding:8px 12px}
button.ring{background:#e08a00;width:100%;font-size:17px}
.days label{display:inline-block;margin:0 10px 6px 0}
.small{font-size:13px;color:#5b6b8c;margin:6px 0}
.head{display:flex;gap:8px;font-size:13px;color:#5b6b8c;margin-bottom:4px}
.head span:first-child{flex:1}
.head span:last-child{width:130px}
#toast{position:fixed;bottom:16px;left:50%;transform:translateX(-50%);background:#222;color:#fff;padding:10px 18px;border-radius:20px;display:none}
.f input{width:100%;margin-bottom:8px}
</style></head><body>
<header><h1>🔔 ESP32 School Bell</h1><div id="clock">Loading...</div></header>
<main>
<div class="card"><button class="ring" onclick="ringNow()">Ring Bell Now (3 sec)</button></div>
<div class="card">
<h2>Bell Timings</h2>
<div class="head"><span>Time</span><span>Ring seconds</span></div>
<div id="rows"></div>
<button class="alt" onclick="addRow('',3)">+ Add More</button>
<button onclick="save()">Save Timings</button>
<p class="small">Saved timings stay in memory even after a power cut.</p>
</div>
<div class="card">
<h2>Ring Days</h2>
<div class="days" id="days"></div>
</div>
<div class="card">
<h2>Clock</h2>
<p class="small" id="rtc"></p>
<button class="alt" onclick="syncTime()">Sync time from this device</button>
</div>
<div class="card f">
<h2>WiFi Settings</h2>
<p class="small" id="wifi"></p>
<input id="ssid" placeholder="WiFi name (SSID)">
<input id="pass" type="password" placeholder="WiFi password">
<button onclick="saveWifi()">Save WiFi & Restart</button>
<p class="small">Forgot the network? Hold the reset button for 5 seconds to clear it.</p>
</div>
</main><div id="toast"></div>
<script>
const $=id=>document.getElementById(id);
const names=['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
let first=true;
function toast(m){const t=$('toast');t.textContent=m;t.style.display='block';setTimeout(()=>t.style.display='none',2200);}
function buildDays(){
let h='';
[1,2,3,4,5,6,0].forEach(i=>{h+='<label><input type="checkbox" class="day" value="'+i+'"> '+names[i]+'</label>';});
$('days').innerHTML=h;
}
function setDays(mask){document.querySelectorAll('.day').forEach(c=>{c.checked=(mask>>c.value)&1;});}
function addRow(t,d){
const div=document.createElement('div');div.className='row';
div.innerHTML='<input type="time" value="'+t+'"><input type="number" min="1" max="60" value="'+d+'"><button class="del" onclick="this.parentNode.remove()">✕</button>';
$('rows').appendChild(div);
}
async function refresh(){
try{
const s=await (await fetch('/api/state')).json();
$('clock').textContent=s.timeOk?s.time:'Clock not set - use Sync time below';
$('wifi').textContent=s.wifi;
$('rtc').textContent=s.rtc?'DS3231 RTC detected - time is kept during power cuts.':'No RTC detected - time comes from WiFi only.';
if(first){first=false;s.sched.forEach(e=>addRow(e.t,e.d));setDays(s.days);}
}catch(e){$('clock').textContent='Bell not reachable';}
}
async function save(){
const items=[];
document.querySelectorAll('#rows .row').forEach(r=>{
const i=r.querySelectorAll('input');
if(i[0].value){
let d=parseInt(i[1].value)||3; d=Math.min(60,Math.max(1,d));
items.push(i[0].value+','+d);
}
});
let mask=0;
document.querySelectorAll('.day').forEach(c=>{if(c.checked)mask|=(1<<c.value);});
const r=await fetch('/api/save',{method:'POST',body:new URLSearchParams({sched:items.join(';'),days:mask})});
toast(r.ok?'Timings saved':'Save failed');
if(r.ok){$('rows').innerHTML='';first=true;refresh();}
}
async function ringNow(){
const r=await fetch('/api/ring',{method:'POST'});
toast(r.ok?'Bell ringing':'Failed');
}
async function syncTime(){
const r=await fetch('/api/settime',{method:'POST',body:new URLSearchParams({epoch:Math.floor(Date.now()/1000)})});
toast(r.ok?'Time synced':'Failed');refresh();
}
async function saveWifi(){
const ssid=$('ssid').value.trim();
if(!ssid){toast('Enter WiFi name');return;}
const r=await fetch('/api/wifi',{method:'POST',body:new URLSearchParams({ssid:ssid,pass:$('pass').value})});
toast(r.ok?'Saved. Restarting...':'Failed');
}
buildDays();refresh();setInterval(refresh,5000);
</script></body></html>
)rawliteral";
// ---------- Helpers ----------
bool timeValid() { return time(nullptr) > 1735689600; } // after 1 Jan 2025
void setSystemTime(time_t t) {
struct timeval tv = { t, 0 };
settimeofday(&tv, nullptr);
}
String jsonEsc(const String& s) {
String o;
for (size_t i = 0; i < s.length(); i++) {
char c = s[i];
if (c == '"' || c == '\\') { o += '\\'; o += c; }
else if ((uint8_t)c >= 32) o += c;
}
return o;
}
bool auth() {
if (!server.authenticate(WEB_USER, WEB_PASS)) {
server.requestAuthentication();
return false;
}
return true;
}
// ---------- Bell control ----------
void ringBell(uint8_t seconds) {
unsigned long until = millis() + seconds * 1000UL;
if (!bellOn) {
digitalWrite(RELAY_PIN, RELAY_ON);
bellOn = true;
bellOffAt = until;
} else if ((long)(until - bellOffAt) > 0) {
bellOffAt = until;
}
}
void updateBell() {
if (bellOn && (long)(millis() - bellOffAt) >= 0) {
digitalWrite(RELAY_PIN, RELAY_OFF);
bellOn = false;
}
}
// ---------- Schedule storage ----------
void sortSchedule() {
for (int i = 1; i < entryCount; i++) {
BellEntry key = schedule[i];
int j = i - 1;
while (j >= 0 && schedule[j].minutes > key.minutes) {
schedule[j + 1] = schedule[j];
j--;
}
schedule[j + 1] = key;
}
}
void parseSchedule(const String& s) {
entryCount = 0;
int start = 0;
while (start < (int)s.length() && entryCount < MAX_ENTRIES) {
int end = s.indexOf(';', start);
if (end < 0) end = s.length();
String item = s.substring(start, end);
int colon = item.indexOf(':');
int comma = item.indexOf(',');
if (colon > 0 && comma > colon) {
int h = item.substring(0, colon).toInt();
int m = item.substring(colon + 1, comma).toInt();
int d = item.substring(comma + 1).toInt();
if (h >= 0 && h < 24 && m >= 0 && m < 60) {
d = constrain(d, 1, 60);
schedule[entryCount].minutes = h * 60 + m;
schedule[entryCount].duration = d;
entryCount++;
}
}
start = end + 1;
}
}
String scheduleToString() {
String s;
for (int i = 0; i < entryCount; i++) {
char b[16];
snprintf(b, sizeof(b), "%02u:%02u,%u", schedule[i].minutes / 60, schedule[i].minutes % 60, schedule[i].duration);
if (i) s += ';';
s += b;
}
return s;
}
void saveSettings() {
prefs.begin("bell", false);
prefs.putString("sched", scheduleToString());
prefs.putUChar("days", daysMask);
prefs.end();
}
void loadSettings() {
prefs.begin("bell", false);
parseSchedule(prefs.getString("sched", ""));
sortSchedule();
daysMask = prefs.getUChar("days", 0x7E);
prefs.end();
}
// ---------- Web handlers ----------
void handleRoot() {
if (!auth()) return;
server.send_P(200, "text/html", PAGE);
}
void handleState() {
if (!auth()) return;
time_t now = time(nullptr);
struct tm t;
localtime_r(&now, &t);
char tb[32];
strftime(tb, sizeof(tb), "%a %d %b %Y, %H:%M:%S", &t);
String wifiText;
if (WiFi.status() == WL_CONNECTED) {
wifiText = "Connected to " + WiFi.SSID() + " (" + WiFi.localIP().toString() + ") - or open http://" + MDNS_NAME + ".local";
} else if (WiFi.getMode() & WIFI_AP) {
wifiText = String("Hotspot mode: ") + AP_SSID + " (" + WiFi.softAPIP().toString() + ")";
} else {
wifiText = "Not connected";
}
String j = "{\"timeOk\":";
j += timeValid() ? "true" : "false";
j += ",\"time\":\"" + String(tb) + "\"";
j += ",\"rtc\":";
j += rtcOk ? "true" : "false";
j += ",\"wifi\":\"" + jsonEsc(wifiText) + "\"";
j += ",\"days\":" + String(daysMask);
j += ",\"sched\":[";
for (int i = 0; i < entryCount; i++) {
char b[8];
snprintf(b, sizeof(b), "%02u:%02u", schedule[i].minutes / 60, schedule[i].minutes % 60);
if (i) j += ",";
j += "{\"t\":\"" + String(b) + "\",\"d\":" + String(schedule[i].duration) + "}";
}
j += "]}";
server.send(200, "application/json", j);
}
void handleSave() {
if (!auth()) return;
if (!server.hasArg("sched") || !server.hasArg("days")) {
server.send(400, "text/plain", "Bad request");
return;
}
parseSchedule(server.arg("sched"));
sortSchedule();
daysMask = server.arg("days").toInt() & 0x7F;
saveSettings();
server.send(200, "text/plain", "OK");
}
void handleRing() {
if (!auth()) return;
ringBell(DEFAULT_DURATION);
server.send(200, "text/plain", "OK");
}
void handleSetTime() {
if (!auth()) return;
long epoch = server.arg("epoch").toInt();
if (epoch < 1735689600L) {
server.send(400, "text/plain", "Bad time");
return;
}
setSystemTime((time_t)epoch);
if (rtcOk) rtc.adjust(DateTime((uint32_t)epoch));
server.send(200, "text/plain", "OK");
}
void handleWifi() {
if (!auth()) return;
String ssid = server.arg("ssid");
if (ssid.length() == 0) {
server.send(400, "text/plain", "SSID required");
return;
}
prefs.begin("bell", false);
prefs.putString("ssid", ssid);
prefs.putString("pass", server.arg("pass"));
prefs.end();
server.send(200, "text/plain", "Restarting");
delay(800);
ESP.restart();
}
// ---------- Network ----------
void startMdns() {
if (mdnsStarted) return;
if (MDNS.begin(MDNS_NAME)) {
MDNS.addService("http", "tcp", 80);
mdnsStarted = true;
Serial.print("Open http://");
Serial.print(MDNS_NAME);
Serial.println(".local");
} else {
Serial.println("mDNS failed to start");
}
}
void startNetwork() {
prefs.begin("bell", true);
String ssid = prefs.getString("ssid", "");
String pass = prefs.getString("pass", "");
prefs.end();
if (ssid.length()) {
WiFi.mode(WIFI_STA);
WiFi.setHostname(MDNS_NAME);
WiFi.begin(ssid.c_str(), pass.c_str());
Serial.print("Connecting to WiFi");
unsigned long st = millis();
while (WiFi.status() != WL_CONNECTED && millis() - st < 15000) {
delay(250);
Serial.print(".");
}
Serial.println();
if (WiFi.status() == WL_CONNECTED) {
startMdns();
Serial.print("Or use IP: http://");
Serial.println(WiFi.localIP());
return;
}
Serial.println("WiFi not available - starting hotspot too");
WiFi.mode(WIFI_AP_STA);
WiFi.softAP(AP_SSID, AP_PASS);
WiFi.begin(ssid.c_str(), pass.c_str()); // keep trying in background
} else {
Serial.println("No saved WiFi - starting hotspot");
WiFi.mode(WIFI_AP);
WiFi.softAP(AP_SSID, AP_PASS);
}
Serial.print("Open http://");
Serial.println(WiFi.softAPIP());
}
// ---------- Loop tasks ----------
void checkSchedule() {
static int lastStamp = -1;
if (!timeValid()) return;
time_t now = time(nullptr);
struct tm t;
localtime_r(&now, &t);
int minuteOfDay = t.tm_hour * 60 + t.tm_min;
int stamp = t.tm_yday * 1440 + minuteOfDay;
if (stamp == lastStamp) return; // already checked this minute
lastStamp = stamp;
if (!(daysMask & (1 << t.tm_wday))) return;
uint8_t longest = 0;
for (int i = 0; i < entryCount; i++) {
if (schedule[i].minutes == minuteOfDay && schedule[i].duration > longest) {
longest = schedule[i].duration;
}
}
if (longest) {
Serial.println("Scheduled bell!");
ringBell(longest);
}
}
void handleButtons() {
// Manual ring
static bool lastManual = HIGH;
static unsigned long lastPress = 0;
bool m = digitalRead(MANUAL_BTN);
if (m == LOW && lastManual == HIGH && millis() - lastPress > 250) {
ringBell(DEFAULT_DURATION);
lastPress = millis();
}
lastManual = m;
// Hold 5 seconds to clear WiFi credentials
static unsigned long holdStart = 0;
if (digitalRead(RESET_BTN) == LOW) {
if (holdStart == 0) holdStart = millis();
if (millis() - holdStart >= RESET_HOLD_MS) {
digitalWrite(STATUS_LED, HIGH);
prefs.begin("bell", false);
prefs.remove("ssid");
prefs.remove("pass");
prefs.end();
Serial.println("WiFi credentials cleared");
while (digitalRead(RESET_BTN) == LOW) delay(10); // wait for release
delay(300);
ESP.restart();
}
} else {
holdStart = 0;
}
}
void syncRtcFromInternet() {
static unsigned long last = 0;
if (!rtcOk || WiFi.status() != WL_CONNECTED || !timeValid()) return;
if (sntp_get_sync_status() != SNTP_SYNC_STATUS_COMPLETED) return;
if (last != 0 && millis() - last < 6UL * 3600UL * 1000UL) return;
last = millis();
rtc.adjust(DateTime((uint32_t)time(nullptr)));
Serial.println("RTC updated from internet time");
}
// ---------- Setup and loop ----------
void setup() {
pinMode(RELAY_PIN, OUTPUT);
digitalWrite(RELAY_PIN, RELAY_OFF); // keep bell silent at boot
pinMode(MANUAL_BTN, INPUT_PULLUP);
pinMode(RESET_BTN, INPUT_PULLUP);
pinMode(STATUS_LED, OUTPUT);
Serial.begin(115200);
delay(200);
loadSettings();
Wire.begin(21, 22);
rtcOk = rtc.begin();
setenv("TZ", TZ_INFO, 1);
tzset();
if (rtcOk) {
if (rtc.lostPower()) {
Serial.println("RTC has no valid time yet - sync from web page or WiFi");
} else {
setSystemTime(rtc.now().unixtime());
Serial.println("Time loaded from RTC");
}
} else {
Serial.println("DS3231 not found - check wiring");
}
startNetwork();
configTzTime(TZ_INFO, "pool.ntp.org", "time.google.com");
server.on("/", HTTP_GET, handleRoot);
server.on("/api/state", HTTP_GET, handleState);
server.on("/api/save", HTTP_POST, handleSave);
server.on("/api/ring", HTTP_POST, handleRing);
server.on("/api/settime", HTTP_POST, handleSetTime);
server.on("/api/wifi", HTTP_POST, handleWifi);
server.begin();
Serial.println("Web server started");
}
void loop() {
server.handleClient();
handleButtons();
checkSchedule();
updateBell();
syncRtcFromInternet();
if (!mdnsStarted && WiFi.status() == WL_CONNECTED) startMdns();
}
How the code handles the tricky parts
Saving after power cuts: the timetable is stored as a small text string like 08:30,3;09:15,5 in the ESP32’s flash using Preferences. It is loaded again in setup() on every boot.
Bell must not ring at startup: the relay pin is set to the OFF state as the very first line of setup(), so a power cut and restart doesn’t trigger a false ring.
No double rings: the schedule is checked once per minute change. If two entries have the same time, the longer duration wins.
Reset button: holding it for 5 seconds erases only the WiFi details, not your timetable. The code waits for you to release the button before restarting. This matters if you use the onboard BOOT button (GPIO 0) instead of an external one, because GPIO 0 held LOW at restart puts the ESP32 into flashing mode.
Working of the ESP32 School Bell

- Upload the code and open the Serial Monitor at 115200 baud.
- First time: there are no saved WiFi details, so the ESP32 starts a hotspot named
SchoolBell-Setup(password12345678). Connect your phone to it and openhttp://192.168.4.1. Log in withadmin/bell1234. - In WiFi Settings, enter your router name and password and press Save WiFi & Restart. After connecting to a wifi network you can use http://schoolbell.local to connect to Schoolbell Dashboard.
- The Serial Monitor now shows an IP address. Open it in your browser (phone and ESP32 must be on the same network).
- Tap Sync time from this device once if the clock says it is not set.
- Press Add More for every period, set the time, and change the seconds if you want a longer bell for lunch or the end of the day. Press Save Timings.
- Test with the Ring Bell Now button on the page and the physical push button.
To test the power cut, unplug the ESP32, wait a minute, and plug it back in. Your timings are still there, and the clock is correct even if your router is off.
Troubleshooting
Relay clicks but the bell does not ring:
Check that the bell wires are on COM and NO, not NC. With NC the bell would ring all the time.
Bell rings constantly, or is silent when it should ring:
Your relay module is probably active HIGH. Swap the RELAY_ON and RELAY_OFF values at the top of the code.
Relay does not switch at all:
Some relay modules are unreliable at 3.3V logic. Power the module’s VCC from the 5V VIN pin (not 3.3V) and check that the GND wires are shared.
“DS3231 not found” in Serial Monitor:
Check SDA and SCL wiring, and try an I2C scanner sketch. The DS3231 normally shows at address 0x68.
Time is wrong by a few hours:
The time zone string TZ_INFO is not set for your location.
Cannot open the web page:
Use the IP address from the Serial Monitor, not a name. Make sure your phone is on the same WiFi, or connected to the SchoolBell-Setup hotspot.
Frequently Asked Questions
Will the bell ring if the WiFi is down?
Yes. Bell timing depends only on the ESP32 and the DS3231 RTC, not on the internet.
How many timings can I add?
Up to 20 in this code. Change MAX_ENTRIES if you need more.
What is the longest ring duration?
60 seconds per timing, which you can change in the code.
Can I use it with a 220V AC bell?
Yes, through the relay’s COM and NO terminals, but only with proper insulated wiring and enclosure. Check that the relay contact rating is higher than the bell’s current.
Conclusion
That’s the complete ESP32 school bell project: an editable timetable on a web page, adjustable ring times, a manual ring button, a 5-second WiFi reset and storage that survives power cuts. It costs a fraction of a commercial bell controller and you can customize it however you like.
Some upgrade ideas are a relay-driven indicator light, an OLED showing the next bell time, a WiFi network scanner on the setup page, or separate timetables for exam days and half days. If you build this project, let us know in the comments how it worked out.
