Auto Fallback Hotspot
If saved WiFi fails after restart/power-cut, device opens Edubell_XXXX hotspot automatically. Connect and reconfigure in seconds.
Bell + Hotspot Together
Uses AP+STA mode. Bell keeps ringing from LittleFS schedule while hotspot is open. Background WiFi retry every 30s.
Full Diagnostics
Checks WiFi → IP → Internet → DNS → NTP in sequence. 3-server NTP fallback. Each failure shows an error code on LCD.
WIFI_FAILDevice cannot join the network. Check SSID/password. Ensure 2.4 GHz band is used (ESP8266 does NOT support 5 GHz).
IP_FAILWiFi joined but no IP assigned. Router DHCP may be full or disabled. Restart router or reserve a DHCP slot.
NO_INTERNETIP obtained but cannot reach 8.8.8.8:53. Router may have internet outage or firewall blocking all outbound TCP.
DNS_FAILCannot resolve pool.ntp.org. Router DNS may be misconfigured. Try setting DNS to 8.8.8.8 in router settings.
NTP_FAILDNS works but NTP timed out. Router is likely blocking UDP port 123. Ask your IT admin to open UDP 123 outbound. Device retries all 3 NTP servers.
(no error)All checks pass. If bell still does not ring, ensure schedule is active in the portal and plan is assigned to the device.
Quick test:
Connect the device to a mobile hotspot first. If time shows and bells ring → your router is the problem (likely UDP 123 blocked). If it also fails on mobile hotspot → check device code/secret credentials.
Required Components
- • NodeMCU ESP8266 (ESP-12E)
- • 5V Relay Module
- • 5V Stable Power Supply (≥1A)
- • [Optional] 16×2 I2C LCD (0x27)
- • [Optional] Push Button (manual bell)
Pin Connections:
Relay IN → D7 (GPIO13)
Config Btn → D6 (GPIO12) + GND
Optional LCD (I2C 0x27):
LCD SDA → D2 (GPIO4)
LCD SCL → D1 (GPIO5)
Optional Manual Button:
Button → D5 (GPIO14) + GND
ArduinoJsonBy Benoit Blanchon — v6.x
LiquidCrystal_I2CBy Frank de Brabander — for LCD display
LittleFSBuilt-in with ESP8266 board package 3.x
ESP8266WiFi / HTTPClientBuilt-in with ESP8266 board package
/*
* Edubell - NodeMCU ESP8266 v7.0 (Power-Cut Recovery + Full Diagnostic)
* Designed and Developed by USHER - The School of Robotics
*
* v7.0 KEY IMPROVEMENTS:
* - Full step-by-step WiFi / DNS / NTP diagnostic sequence
* - Separate detection of: WiFi fail, IP fail, DNS fail, NTP fail
* - 3-server NTP fallback: pool.ntp.org → time.google.com → time.nist.gov
* - DNS pre-check before NTP to isolate router DNS blocking
* - Bell runs from LittleFS schedule + last valid time even if router blocks NTP
* - 10-screen LCD rotation with per-step diagnostic status
* - Extended heartbeat: ssid, dns_ok, ntp_sync_ok, scheduler_status, last_error
*
* COMMON ROUTER ISSUE (why mobile hotspot works but router does not):
* - Router may block UDP port 123 (NTP) — contact your IT/network admin
* - Router DNS may not resolve external hostnames — try 8.8.8.8
* - Some routers block HTTPS from IoT devices — check firewall
*
* Hardware:
* BELL RELAY : D7 (GPIO13)
* CFG BUTTON : D6 (GPIO12) — hold 3s at boot to reset WiFi
* MANUAL BTN : D5 (GPIO14)
* LCD SDA : D2 (GPIO4) [16x2 I2C LCD at 0x27]
* LCD SCL : D1 (GPIO5)
*
* Libraries (Arduino Library Manager):
* ArduinoJson v6.x | LiquidCrystal_I2C | LittleFS (built-in ESP8266 3.x)
*
* Board: NodeMCU 1.0 (ESP-12E)
* Flash: 4MB (FS:2MB OTA:~1019KB) ← MUST be set correctly for LittleFS
*
* WiFi setup: device broadcasts Edubell-{DEVICE_CODE}, pw: edubell123
* Open http://192.168.4.1 in a browser to configure WiFi
*/
#include <ESP8266WiFi.h>
#include <ESP8266WebServer.h>
#include <DNSServer.h>
#include <ESP8266HTTPClient.h>
#include <WiFiClientSecure.h>
#include <ArduinoJson.h>
#include <EEPROM.h>
#include <LittleFS.h>
#include <time.h>
#include <Wire.h>
#include <LiquidCrystal_I2C.h>
// ─── Device credentials ────────────────────────────────────────────────────────
const char* API_URL = "https://smartbellusher.base44.app/api/functions/deviceApi";
const char* DEVICE_CODE = "1234567"; // ← Replace with your device code
const char* DEVICE_SECRET = "your_device_secret_here"; // ← Replace with your device secret
const char* AP_PASSWORD = "edubell123";
const char* FIRMWARE_VER = "7.0.0";
const int WIFI_TIMEOUT_MS = 20000; // ms — open fallback hotspot if WiFi fails within this time
// ─── NTP servers (tried in order) ─────────────────────────────────────────────
const char* NTP_SERVERS[] = { "pool.ntp.org", "time.google.com", "time.nist.gov" };
const int NTP_COUNT = 3;
// ─── Hardware pins ────────────────────────────────────────────────────────────
#define BELL_PIN D7
#define CFG_BTN D6
#define MANUAL_BTN D5
// ─── I2C LCD ──────────────────────────────────────────────────────────────────
LiquidCrystal_I2C lcd(0x27, 16, 2);
// ─── Schedule ─────────────────────────────────────────────────────────────────
struct BellEntry { char t[6]; char lbl[24]; int dur; bool fired; };
BellEntry bells[60];
int bellCount = 0;
// ─── Config (server-overridable defaults) ─────────────────────────────────────
char br1[17] = "USHER";
char br2[17] = "Edubell";
int lcdSec = 4;
bool manBtn = true;
int manDur = 5;
long tzOff = 19800; // IST UTC+5:30
unsigned long ntpMs = 3600000UL;
// ─── Diagnostic state ─────────────────────────────────────────────────────────
bool wifiConnected = false;
bool ipObtained = false;
bool internetReach = false;
bool dnsOK = false;
bool ntpSyncOK = false;
bool timeSynced = false;
bool isOnline = false;
bool schedFromMemory = false;
bool hasLocalSched = false;
bool apActive = false; // true = fallback hotspot is running
char lastErrCode[20] = "";
char lastErrMsg[64] = "";
char schedulerStatus[16] = "Waiting";
char scheduleSource[16] = "None";
// ─── Runtime state ────────────────────────────────────────────────────────────
char planName[32] = "None";
char nxtT[8] = "";
char nxtL[24] = "";
int schedVer = 0;
int lastDay = -1;
// ─── Timers ───────────────────────────────────────────────────────────────────
unsigned long tCfg=0, tHB=0, tNTP=0, tLcd=0, tBell=0, tMan=0, tApLcd=0, tRetry=0;
// ─── LCD page ─────────────────────────────────────────────────────────────────
int lcdPg = 0;
int apLcdPg = 0;
#define LCD_SCREENS 10
// ─── AP mode ──────────────────────────────────────────────────────────────────
bool apMode = false;
ESP8266WebServer srv(80);
DNSServer dns;
struct WCfg { uint8_t mag; char ssid[33]; char pass[64]; char dc[16]; };
WCfg wc;
#define EMAG 0xEF
// =============================================================================
// LCD
// =============================================================================
void L(const char* a, const char* b) {
lcd.clear();
lcd.setCursor(0,0); lcd.print(a);
lcd.setCursor(0,1); lcd.print(b);
}
void lcdTick() {
unsigned long now = millis();
if (now - tLcd < (unsigned long)lcdSec * 1000UL) return;
tLcd = now;
lcdPg = (lcdPg + 1) % LCD_SCREENS;
switch (lcdPg) {
// 0: Branding
case 0: L(br1, br2); break;
// 1: WiFi status
case 1: L("WiFi", wifiConnected ? "Connected" : "Failed"); break;
// 2: IP address
case 2: {
if (ipObtained) {
char ipStr[17];
String ip = WiFi.localIP().toString();
ip.substring(0,16).toCharArray(ipStr,17);
L("IP Address", ipStr);
} else {
L("IP Address", "No IP");
}
break;
}
// 3: Internet reachability
case 3: L("Internet", internetReach ? "Available" : "No Internet"); break;
// 4: DNS status
case 4: L("DNS", dnsOK ? "OK" : "Failed"); break;
// 5: Time sync status
case 5: L("Time Sync", ntpSyncOK ? "Success" : "Failed"); break;
// 6: Current time
case 6: {
if (timeSynced) {
time_t t = time(nullptr); struct tm* ti = localtime(&t);
char tb[17]; snprintf(tb,16,"%02d:%02d:%02d",ti->tm_hour,ti->tm_min,ti->tm_sec);
L("Current Time", tb);
} else {
L("Current Time", "Syncing...");
}
break;
}
// 7: Scheduler mode
case 7: L("Mode", schedulerStatus); break;
// 8: Next bell
case 8: {
if (strlen(nxtT)) {
char b[17]; snprintf(b,16,"%.5s %.9s",nxtT,nxtL);
L("Next Bell", b);
} else if (!timeSynced && bellCount>0) {
L("Next Bell", "Syncing...");
} else {
L("Next Bell", bellCount ? "Done today" : "No schedule");
}
break;
}
// 9: Error screen (only shows if there is an error)
case 9: {
if (strlen(lastErrCode) > 0) {
L("Error", lastErrCode);
} else {
L("Status", "All OK");
}
break;
}
}
}
// =============================================================================
// UTILITIES
// =============================================================================
void setError(const char* code, const char* msg) {
strncpy(lastErrCode, code, 19); lastErrCode[19]=0;
strncpy(lastErrMsg, msg, 63); lastErrMsg[63]=0;
Serial.printf("[ERR] %s: %s\n", code, msg);
}
void clearError() { lastErrCode[0]=0; lastErrMsg[0]=0; }
long tzOff2(const char* z) {
if(strstr(z,"Kolkata")||strstr(z,"India")) return 19800;
if(strstr(z,"Dubai")||strstr(z,"Gulf")) return 14400;
if(strstr(z,"London")) return 0;
if(strstr(z,"Paris")||strstr(z,"Berlin")) return 3600;
if(strstr(z,"New_York")||strstr(z,"Eastern")) return -18000;
if(strstr(z,"Chicago")||strstr(z,"Central")) return -21600;
if(strstr(z,"Denver")||strstr(z,"Mountain")) return -25200;
if(strstr(z,"Los_Angeles")||strstr(z,"Pacific"))return -28800;
if(strstr(z,"Tokyo")||strstr(z,"Japan")) return 32400;
if(strstr(z,"Singapore")||strstr(z,"Kuala")) return 28800;
if(strstr(z,"Sydney")||strstr(z,"Melbourne")) return 36000;
return 19800;
}
int bellMins(int i) {
return (bells[i].t[0]-'0')*600 + (bells[i].t[1]-'0')*60
+ (bells[i].t[3]-'0')*10 + (bells[i].t[4]-'0');
}
void markPast() {
time_t t=time(nullptr); if(t<100000)return;
struct tm* ti=localtime(&t); int cur=ti->tm_hour*60+ti->tm_min;
for(int i=0;i<bellCount;i++) if(bellMins(i)<cur) bells[i].fired=true;
}
void calcNext() {
nxtT[0]=0; nxtL[0]=0;
time_t t=time(nullptr); if(t<100000)return;
struct tm* ti=localtime(&t); int cur=ti->tm_hour*60+ti->tm_min;
for(int i=0;i<bellCount;i++){
if(bells[i].fired)continue;
if(bellMins(i)>cur){ snprintf(nxtT,8,"%s",bells[i].t); strncpy(nxtL,bells[i].lbl,23); return; }
}
}
void updateSchedulerStatus() {
if(bellCount==0){ strncpy(schedulerStatus,"No Schedule",15); return; }
if(!timeSynced){ strncpy(schedulerStatus,"Waiting Time",15); return; }
strncpy(schedulerStatus, schedFromMemory ? "Offline Run" : "Live Sync",15);
}
// =============================================================================
// LITTLEFS (offline schedule)
// =============================================================================
void saveFlash(){
DynamicJsonDocument doc(8192);
doc["p"]=planName; doc["v"]=schedVer; doc["z"]=tzOff;
JsonArray a=doc.createNestedArray("e");
for(int i=0;i<bellCount;i++){JsonObject o=a.createNestedObject();o["t"]=bells[i].t;o["l"]=bells[i].lbl;o["d"]=bells[i].dur;}
File f=LittleFS.open("/s.json","w"); if(!f)return;
serializeJson(doc,f); f.close(); hasLocalSched=true;
Serial.printf("Flash saved v%d %d bells\n",schedVer,bellCount);
}
bool loadFlash(){
if(!LittleFS.exists("/s.json"))return false;
File f=LittleFS.open("/s.json","r"); if(!f)return false;
DynamicJsonDocument doc(8192);
if(deserializeJson(doc,f)){f.close();return false;} f.close();
strncpy(planName,doc["p"]|"Unknown",31); planName[31]=0;
schedVer=doc["v"]|0; tzOff=doc["z"]|19800; bellCount=0;
for(JsonObject o:doc["e"].as<JsonArray>()){
strncpy(bells[bellCount].t,o["t"]|"00:00",5); bells[bellCount].t[5]=0;
strncpy(bells[bellCount].lbl,o["l"]|"",23); bells[bellCount].lbl[23]=0;
bells[bellCount].dur=o["d"]|5; bells[bellCount].fired=false;
if(++bellCount>=60)break;
}
hasLocalSched=true; schedFromMemory=true;
strncpy(scheduleSource,"LittleFS",15);
Serial.printf("Flash loaded v%d %d bells\n",schedVer,bellCount);
return true;
}
void checkDeviceId(){
char s[16]="";
if(LittleFS.exists("/id.txt")){File f=LittleFS.open("/id.txt","r");if(f){f.readBytes(s,15);f.close();}}
if(strcmp(s,DEVICE_CODE)!=0){
LittleFS.remove("/s.json");
File f=LittleFS.open("/id.txt","w");if(f){f.print(DEVICE_CODE);f.close();}
}
}
// =============================================================================
// EEPROM
// =============================================================================
void loadWC(){ EEPROM.get(0,wc); if(wc.mag!=EMAG||strncmp(wc.dc,DEVICE_CODE,15)!=0)memset(&wc,0,sizeof(wc)); }
void saveWC(){ wc.mag=EMAG; strncpy(wc.dc,DEVICE_CODE,15); EEPROM.put(0,wc); EEPROM.commit(); }
void clearWC(){ memset(&wc,0,sizeof(wc)); EEPROM.put(0,wc); EEPROM.commit(); }
bool hasWC(){ return wc.mag==EMAG&&strlen(wc.ssid)>0; }
// =============================================================================
// HTTPS POST helper
// =============================================================================
String httpPost(const char* body, int ms=10000){
if(WiFi.status()!=WL_CONNECTED)return "";
WiFiClientSecure cl; cl.setInsecure();
HTTPClient hc; hc.begin(cl,String(API_URL));
hc.addHeader("Content-Type","application/json"); hc.setTimeout(ms);
int code=hc.POST(body); String r="";
if(code==200) r=hc.getString();
else Serial.printf("HTTP %d\n",code);
hc.end(); return r;
}
// =============================================================================
// DIAGNOSTIC: Check internet reachability (quick TCP connect to 8.8.8.8:53)
// =============================================================================
bool checkInternet(){
WiFiClient cl;
bool ok = cl.connect(IPAddress(8,8,8,8), 53, 3000);
if(ok) cl.stop();
internetReach = ok;
if(!ok) setError("NO_INTERNET","Cannot reach 8.8.8.8:53");
return ok;
}
// =============================================================================
// DIAGNOSTIC: DNS resolution check
// =============================================================================
bool checkDNS(){
IPAddress resolved;
bool ok = WiFi.hostByName("pool.ntp.org", resolved, 5000);
dnsOK = ok;
if(!ok){
setError("DNS_FAIL","Unable to resolve NTP hostname");
Serial.println("[DIAG] DNS failed — router may be blocking DNS or NTP");
} else {
Serial.printf("[DIAG] DNS OK: pool.ntp.org = %s\n", resolved.toString().c_str());
}
return ok;
}
// =============================================================================
// NTP SYNC with 3-server fallback
// =============================================================================
void ntpStart(){
if(WiFi.status()!=WL_CONNECTED) return;
configTime(tzOff, 0, "pool.ntp.org", "time.google.com", "time.nist.gov");
tNTP=millis();
Serial.println("[NTP] Configured all 3 servers, waiting for time...");
}
void ntpCheck(){
if(timeSynced || WiFi.status()!=WL_CONNECTED) return;
if(time(nullptr)>100000UL){
timeSynced=true; ntpSyncOK=true; clearError();
time_t nowT=time(nullptr); struct tm* ti=localtime(&nowT);
if(lastDay!=ti->tm_mday){
for(int i=0;i<bellCount;i++) bells[i].fired=false;
lastDay=ti->tm_mday;
}
markPast(); calcNext(); updateSchedulerStatus();
Serial.printf("[NTP] Synced — %02d:%02d (tz=%lds)\n", ti->tm_hour, ti->tm_min, tzOff);
tLcd=0;
} else if(millis()-tNTP>=8000UL){
configTime(tzOff, 0, "pool.ntp.org", "time.google.com", "time.nist.gov");
tNTP=millis();
Serial.println("[NTP] Retrying configTime...");
}
}
// =============================================================================
// FULL DIAGNOSTIC SEQUENCE (called after WiFi connect)
// =============================================================================
void runDiagnostics(){
Serial.println("\n[DIAG] === Starting diagnostic sequence ===");
// Step 1: WiFi + IP
wifiConnected = (WiFi.status()==WL_CONNECTED);
ipObtained = wifiConnected && (WiFi.localIP() != IPAddress(0,0,0,0));
Serial.printf("[DIAG] WiFi: %s IP: %s\n",
wifiConnected?"OK":"FAIL", ipObtained?WiFi.localIP().toString().c_str():"No IP");
if(!wifiConnected){ setError("WIFI_FAIL","WiFi not connected"); return; }
if(!ipObtained) { setError("IP_FAIL","No IP assigned by router"); return; }
// Step 2: NTP — non-blocking start, loop detects when time arrives
ntpStart();
// Step 3: Internet reachability (for diagnostic display only)
L("Checking...", "Internet");
delay(200);
checkInternet();
if(!internetReach){
L("No Internet", "Check router");
delay(1000);
}
// Step 4: DNS
L("Checking...", "DNS");
delay(200);
checkDNS();
if(!dnsOK) {
L("DNS Failed", "NTP may fail");
delay(1000);
}
Serial.println("[DIAG] === Sequence complete ===");
Serial.printf("[DIAG] WiFi=%d IP=%d Internet=%d DNS=%d NTP=%d\n",
wifiConnected, ipObtained, internetReach, dnsOK, ntpSyncOK);
}
// =============================================================================
// AUTH
// =============================================================================
bool doAuth(){
char b[200]; snprintf(b,200,"{\"action\":\"auth\",\"device_code\":\"%s\",\"device_secret\":\"%s\"}",DEVICE_CODE,DEVICE_SECRET);
String r=httpPost(b,8000); if(!r.length())return false;
DynamicJsonDocument doc(512);
if(deserializeJson(doc,r)||!doc["success"])return false;
tzOff=tzOff2(doc["timezone"]|"Asia/Kolkata");
Serial.printf("[AUTH] OK tz=%lds\n",tzOff); return true;
}
// =============================================================================
// CONFIG
// =============================================================================
void doConfig(){
char b[200]; snprintf(b,200,"{\"action\":\"config\",\"device_code\":\"%s\",\"device_secret\":\"%s\"}",DEVICE_CODE,DEVICE_SECRET);
String r=httpPost(b,12000); tCfg=millis(); if(!r.length())return;
DynamicJsonDocument doc(8192);
if(deserializeJson(doc,r)||!doc["success"]){Serial.println("[CFG] parse fail");return;}
JsonObject lc=doc["lcd"];
lcdSec=lc["rotation_interval"]|4;
strncpy(br1,lc["brand_line_1"]|"USHER",16); br1[16]=0;
strncpy(br2,lc["brand_line_2"]|"Edubell",16); br2[16]=0;
JsonObject mb=doc["manual_button"];
manDur=mb["ring_duration_seconds"]|5;
ntpMs=((unsigned long)(doc["ntp_sync_interval_minutes"]|60))*60000UL;
long nz=tzOff2(doc["timezone"]|"Asia/Kolkata");
if(nz!=tzOff){ tzOff=nz; syncNTP(); }
const char* pn=doc["active_plan_name"]|"";
if(strlen(pn)>0){ strncpy(planName,pn,31); planName[31]=0; }
else strncpy(planName,"No Plan",31);
JsonArray en=doc["schedule_entries"];
if(!en.size()) en=doc["bell_times"].as<JsonArray>();
int nc=0;
for(JsonObject e:en){
strncpy(bells[nc].t,e["time"]|"00:00",5); bells[nc].t[5]=0;
strncpy(bells[nc].lbl,e["label"]|"",23); bells[nc].lbl[23]=0;
bells[nc].dur=e["duration_seconds"]|e["duration"]|5;
bells[nc].fired=false; if(++nc>=60)break;
}
if(nc>0){
bellCount=nc; schedVer=doc["schedule_version"]|1;
schedFromMemory=false; hasLocalSched=true;
strncpy(scheduleSource,"Live Sync",15);
markPast(); saveFlash();
Serial.printf("[CFG] plan=%s v=%d bells=%d\n",planName,schedVer,bellCount);
}
calcNext(); updateSchedulerStatus(); tLcd=0;
}
// =============================================================================
// HEARTBEAT (extended diagnostics)
// =============================================================================
void doHB(){
if(WiFi.status()!=WL_CONNECTED)return;
time_t t=time(nullptr); struct tm* ti=localtime(&t);
char tb[20]; snprintf(tb,20,"%02d:%02d:%02d",ti->tm_hour,ti->tm_min,ti->tm_sec);
char ssidEsc[36]; strncpy(ssidEsc, wc.ssid, 33);
char b[700];
snprintf(b,700,
"{\"action\":\"heartbeat\","
"\"device_code\":\"%s\",\"device_secret\":\"%s\","
"\"wifi_signal\":%d,\"firmware_version\":\"%s\","
"\"ip_address\":\"%s\",\"ssid\":\"%s\","
"\"wifi_connected\":%s,\"internet_reachable\":%s,"
"\"dns_ok\":%s,\"ntp_sync_ok\":%s,"
"\"time_synced\":%s,\"internet_connected\":%s,"
"\"local_schedule_version\":%d,\"has_local_schedule\":%s,"
"\"device_mode\":\"%s\","
"\"scheduler_status\":\"%s\","
"\"schedule_source\":\"%s\","
"\"current_device_time\":\"%s\","
"\"next_bell_time\":\"%s\","
"\"last_error_code\":\"%s\","
"\"last_error_message\":\"%s\"}",
DEVICE_CODE, DEVICE_SECRET,
WiFi.RSSI(), FIRMWARE_VER,
WiFi.localIP().toString().c_str(), ssidEsc,
wifiConnected?"true":"false",
internetReach?"true":"false",
dnsOK?"true":"false",
ntpSyncOK?"true":"false",
timeSynced?"true":"false",
isOnline?"true":"false",
schedVer, hasLocalSched?"true":"false",
schedFromMemory?"offline_memory":(timeSynced?"online":"waiting_time"),
schedulerStatus,
scheduleSource,
tb, nxtT,
lastErrCode, lastErrMsg
);
String r=httpPost(b,8000);
if(r.length()){
DynamicJsonDocument doc(256);
if(!deserializeJson(doc,r)&&(doc["needs_schedule_update"]|false)) doConfig();
}
tHB=millis();
}
// =============================================================================
// BELL LOG
// =============================================================================
void logBell(const char* bt,const char* lbl,const char* src){
char b[240]; snprintf(b,240,"{\"action\":\"bell_triggered\",\"device_code\":\"%s\",\"device_secret\":\"%s\",\"bell_time\":\"%s\",\"bell_label\":\"%s\",\"source\":\"%s\"}",DEVICE_CODE,DEVICE_SECRET,bt,lbl,src);
httpPost(b,5000);
}
// =============================================================================
// BELL CHECK (every second) — works offline if timeSynced is true
// =============================================================================
void bellTick(){
if(!bellCount||!timeSynced)return;
time_t t=time(nullptr); if(t<100000)return;
struct tm* ti=localtime(&t);
int h=ti->tm_hour,m=ti->tm_min,s=ti->tm_sec,d=ti->tm_mday;
if(d!=lastDay){for(int i=0;i<bellCount;i++)bells[i].fired=false;lastDay=d;nxtT[0]=0;calcNext();}
if(s>30)return; // trigger window: first 30s of the minute
for(int i=0;i<bellCount;i++){
if(bells[i].fired)continue;
if(bellMins(i)==h*60+m){
Serial.printf("[BELL] %02d:%02d %s %ds\n",h,m,bells[i].lbl,bells[i].dur);
L("Bell Ringing!", bells[i].lbl);
digitalWrite(BELL_PIN,HIGH); delay(bells[i].dur*1000); digitalWrite(BELL_PIN,LOW);
bells[i].fired=true; tLcd=0;
logBell(bells[i].t,bells[i].lbl,schedFromMemory?"offline_memory":"online");
calcNext(); updateSchedulerStatus(); break;
}
}
}
// =============================================================================
// CAPTIVE PORTAL (WiFi setup)
// =============================================================================
void hRoot(){
String h="<!DOCTYPE html><html><head><meta name='viewport' content='width=device-width,initial-scale=1'>"
"<style>*{box-sizing:border-box}body{font-family:Arial,sans-serif;background:#f5f5f5;margin:0;padding:20px}"
".c{max-width:400px;margin:0 auto;background:#fff;padding:24px;border-radius:12px;box-shadow:0 2px 8px rgba(0,0,0,.1)}"
"h2{text-align:center;color:#1e40af;margin:0 0 4px}p{text-align:center;color:#666;font-size:13px;margin:0 0 20px}"
".tip{background:#fef9c3;border:1px solid #fde047;border-radius:6px;padding:10px;font-size:12px;margin-bottom:16px}"
"label{display:block;font-size:13px;font-weight:600;margin-bottom:4px;color:#374151}"
"select,input{width:100%;padding:10px;border:1px solid #d1d5db;border-radius:6px;margin-bottom:14px;font-size:14px}"
"button{width:100%;padding:12px;background:#2563eb;color:#fff;border:none;border-radius:6px;font-size:15px;cursor:pointer}"
"</style></head><body><div class='c'>"
"<h2>Edubell Setup</h2><p>Device: <b>";
h+=DEVICE_CODE;
h+="</b></p>"
"<div class='tip'>⚠ Use 2.4 GHz WiFi only. If time does not show after connecting, "
"your router may block UDP port 123 (NTP). Try a mobile hotspot to test.</div>"
"<form method='POST' action='/save'>"
"<label>Select WiFi Network</label>"
"<select name='ssid' id='w'><option>Scanning...</option></select>"
"<label>WiFi Password</label>"
"<input type='password' name='pass' placeholder='Enter password'>"
"<button type='submit'>Save & Connect</button>"
"</form></div>"
"<script>fetch('/scan').then(r=>r.json()).then(d=>{"
"var s=document.getElementById('w');s.innerHTML='';"
"d.n.forEach(function(x){var o=document.createElement('option');o.value=x.s;"
"o.textContent=x.s+' ('+x.r+' dBm)';s.appendChild(o)}"
")}).catch(function(){document.getElementById('w').innerHTML="
"'<option>Scan failed - type SSID manually</option>';});"
"</script></body></html>";
srv.send(200,"text/html",h);
}
void hScan(){
int n=WiFi.scanNetworks(false,true);
String j=String(char(123))+char(34)+"n"+char(34)+":[";
for(int i=0;i<n&&i<20;i++){
if(i)j+=",";
String ssid=WiFi.SSID(i);
for(int k=0;k<(int)ssid.length();k++) if(ssid[k]==34||ssid[k]==92) ssid[k]='_';
j+=String(char(123))+char(34)+"s"+char(34)+":"+char(34)+ssid+char(34)+","+char(34)+"r"+char(34)+":"+String(WiFi.RSSI(i))+"}";
}
j+="]}"; srv.send(200,"application/json",j);
}
void hSave(){
srv.arg("ssid").toCharArray(wc.ssid,33);
srv.arg("pass").toCharArray(wc.pass,64);
saveWC();
srv.send(200,"text/html","<html><body style='font-family:Arial;text-align:center;padding:40px;background:#f5f5f5'>"
"<div style='max-width:380px;margin:0 auto;background:#fff;padding:24px;border-radius:12px'>"
"<h2 style='color:#16a34a'>Saved!</h2><p>Device is restarting...</p></div></body></html>");
delay(1500); ESP.restart();
}
void startAP(){
apMode=true;
String ap="Edubell-"+String(DEVICE_CODE);
WiFi.disconnect(); WiFi.mode(WIFI_AP);
WiFi.softAP(ap.c_str(),AP_PASSWORD);
delay(100);
IPAddress ip=WiFi.softAPIP();
Serial.printf("[AP] %s ip=%s\n",ap.c_str(),ip.toString().c_str());
L("Hotspot Active",ap.substring(0,16).c_str());
dns.start(53,"*",ip);
srv.on("/",HTTP_GET,hRoot); srv.on("/scan",HTTP_GET,hScan);
srv.on("/save",HTTP_POST,hSave); srv.onNotFound(hRoot);
srv.begin();
}
// AP-mode LCD rotation (4 screens for setup guidance)
void apLcdTick(){
unsigned long now=millis();
if(now-tApLcd < (unsigned long)4*1000UL) return;
tApLcd=now; apLcdPg=(apLcdPg+1)%4;
String ap="Edubell_"+String(DEVICE_CODE).substring(3);
switch(apLcdPg){
case 0: L("Setup Hotspot",ap.substring(0,16).c_str()); break;
case 1: L("Connect To",ap.substring(0,16).c_str()); break;
case 2: L("Open Setup","192.168.4.1"); break;
case 3: L("Status",hasLocalSched&&timeSynced?"Bell: Running":"Waiting WiFi"); break;
}
}
// ─── Start fallback hotspot (AP+STA mode so bell still runs offline)
void startFallbackAP(){
apActive=true;
String ap="Edubell_"+String(DEVICE_CODE).substring(3);
WiFi.mode(WIFI_AP_STA);
WiFi.softAP(ap.c_str(), AP_PASSWORD);
delay(100);
IPAddress ip=WiFi.softAPIP();
Serial.printf("[AP] Fallback: %s IP=%s\n",ap.c_str(),ip.toString().c_str());
L("WiFi Failed","Starting Setup");
delay(700);
L("Setup Hotspot",ap.substring(0,16).c_str());
dns.start(53,"*",ip);
srv.on("/",HTTP_GET,hRoot);
srv.on("/scan",HTTP_GET,hScan);
srv.on("/save",HTTP_POST,hSave);
srv.onNotFound(hRoot);
srv.begin();
tApLcd=0;
}
// =============================================================================
// SETUP
// =============================================================================
void setup(){
Serial.begin(115200);
Serial.printf("\n\n=== Edubell v7.0 | %s ===\n",DEVICE_CODE);
Serial.println("Power-Cut Recovery Edition");
pinMode(BELL_PIN,OUTPUT); digitalWrite(BELL_PIN,LOW);
pinMode(CFG_BTN,INPUT_PULLUP);
pinMode(MANUAL_BTN,INPUT_PULLUP);
Wire.begin(D2,D1);
lcd.init(); lcd.backlight(); lcd.clear();
L("Edubell v7.0","Booting...");
delay(500);
EEPROM.begin(512);
if(!LittleFS.begin()){ LittleFS.format(); LittleFS.begin(); }
checkDeviceId();
// Brief bell test
L("Edubell","Testing bell");
digitalWrite(BELL_PIN,HIGH); delay(400); digitalWrite(BELL_PIN,LOW);
// Load stored schedule FIRST — bells ring from flash even without internet
if(loadFlash()){ L("Plan loaded:",planName); delay(700); }
else { L("No Schedule","Need WiFi sync"); delay(700); }
loadWC();
// Hold CFG button 3s at boot → clear WiFi + open setup
if(digitalRead(CFG_BTN)==LOW){
L("Hold to Reset","Release to skip");
unsigned long t0=millis();
while(digitalRead(CFG_BTN)==LOW&&millis()-t0<3000) yield();
if(digitalRead(CFG_BTN)==LOW){
clearWC(); L("WiFi Cleared","Opening Setup"); delay(800);
startFallbackAP(); return;
}
}
// No saved credentials → open setup
if(!hasWC()){
if(hasLocalSched){ schedFromMemory=true; }
startFallbackAP(); // always open AP if no WiFi configured
return;
}
// ─── Attempt WiFi with hard timeout — then open fallback AP if it fails ────
Serial.printf("[WiFi] Connecting: %s (timeout %dms)\n",wc.ssid,WIFI_TIMEOUT_MS);
L("Connecting...",wc.ssid);
WiFi.mode(WIFI_STA); WiFi.begin(wc.ssid,wc.pass);
unsigned long wt=millis();
while(WiFi.status()!=WL_CONNECTED && millis()-wt<(unsigned long)WIFI_TIMEOUT_MS){ yield(); }
if(WiFi.status()==WL_CONNECTED){
wifiConnected=true; ipObtained=true; isOnline=true;
Serial.printf("[WiFi] OK IP=%s\n",WiFi.localIP().toString().c_str());
L("WiFi Connected",WiFi.localIP().toString().c_str());
delay(400);
runDiagnostics(); // full internet/DNS/NTP diagnostic
if(doAuth()){
doConfig(); schedFromMemory=false;
strncpy(scheduleSource,"Live Sync",15);
} else {
L("Auth Failed","Offline mode");
if(hasLocalSched) schedFromMemory=true;
}
if(!timeSynced&&hasLocalSched){ L("Offline Mode","Waiting NTP"); }
calcNext(); updateSchedulerStatus();
tLcd=0; tCfg=millis(); tHB=millis(); tNTP=millis(); tBell=millis();
} else {
// ─── WiFi failed → ALWAYS open fallback hotspot ──────────────────────────
Serial.printf("[WiFi] FAILED after %dms — launching fallback AP\n",WIFI_TIMEOUT_MS);
wifiConnected=false; ipObtained=false; isOnline=false;
setError("WIFI_FAIL","Could not connect to saved WiFi");
if(hasLocalSched) schedFromMemory=true;
startFallbackAP();
tRetry=millis(); tBell=millis();
}
}
// =============================================================================
// LOOP — AP and bell both run simultaneously (AP+STA mode)
// =============================================================================
void loop(){
unsigned long now=millis();
// ─── AP active: serve portal + bell + background retry ─────────────────────
if(apActive){
dns.processNextRequest();
srv.handleClient();
// Background WiFi retry every 30s
if(hasWC() && now-tRetry>=30000UL){
tRetry=now;
if(WiFi.status()==WL_CONNECTED){
Serial.println("[WiFi] Reconnected while in AP mode — resuming normal ops");
apActive=false; isOnline=true; wifiConnected=true;
dns.stop(); srv.stop();
L("WiFi Connected","Online Mode");
delay(400);
runDiagnostics();
if(doAuth()) doConfig();
calcNext(); updateSchedulerStatus();
tLcd=0; tCfg=now; tHB=now; tNTP=now;
} else {
WiFi.disconnect();
WiFi.begin(wc.ssid,wc.pass);
Serial.printf("[WiFi] Background retry: %s\n",wc.ssid);
}
}
// Bell keeps running in AP mode from offline schedule
if(now-tBell>=1000UL){ ntpCheck(); bellTick(); tBell=now; }
// Manual button
if(manBtn&&digitalRead(MANUAL_BTN)==LOW&&now-tMan>500){
tMan=now; L("Manual Bell","Ringing!");
digitalWrite(BELL_PIN,HIGH); delay(manDur*1000); digitalWrite(BELL_PIN,LOW);
tLcd=0; logBell("00:00","Manual","manual_button");
}
apLcdTick();
delay(20);
return;
}
// ─── Normal mode ──────────────────────────────────────────────────────────
bool wasOnline=isOnline;
isOnline=(WiFi.status()==WL_CONNECTED);
wifiConnected=isOnline;
// Reconnected after drop
if(!wasOnline&&isOnline){
Serial.println("[WiFi] Reconnected");
runDiagnostics(); if(doAuth()) doConfig(); schedFromMemory=false;
}
// WiFi dropped during normal operation → open fallback AP (device must not hide)
if(wasOnline&&!isOnline){
Serial.println("[WiFi] Lost — starting fallback AP");
schedFromMemory=hasLocalSched;
startFallbackAP(); tRetry=now; tBell=now; return;
}
if(isOnline){
ntpCheck();
if(timeSynced && now-tNTP>=ntpMs) { ntpStart(); }
if(now-tCfg>=300000UL) { doConfig(); }
if(now-tHB>=30000UL) { doHB(); }
}
if(now-tBell>=1000UL){ bellTick(); tBell=now; }
if(manBtn&&digitalRead(MANUAL_BTN)==LOW&&now-tMan>500){
tMan=now; L("Manual Bell","Ringing!");
digitalWrite(BELL_PIN,HIGH); delay(manDur*1000); digitalWrite(BELL_PIN,LOW);
tLcd=0; logBell("00:00","Manual","manual_button");
}
lcdTick();
delay(20);
}
- 1Install Arduino IDE. Add ESP8266 board: Preferences → Additional Boards Manager URLs → http://arduino.esp8266.com/stable/package_esp8266com_index.json
- 2Install ArduinoJson v6.x and LiquidCrystal_I2C from Library Manager
- 3Download .ino file — update DEVICE_CODE and DEVICE_SECRET near the top
- 4Tools → Board → NodeMCU 1.0 (ESP-12E)
- 5Tools → Flash Size → 4MB (FS:2MB OTA:~1019KB) ← REQUIRED for LittleFS
- 6Connect NodeMCU via USB, select correct COM port, click Upload
- 7Open Serial Monitor at 115200 baud — watch the diagnostic sequence output
- 8If DNS_FAIL or NTP_FAIL appears: ask your IT admin to open UDP port 123 on the router