Edubell — NodeMCU ESP8266 Firmware v7.0

Power-Cut Recovery + Full Diagnostic — Auto Fallback Hotspot

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.

LCD Diagnostic Screens (10 screens)
16×2 I2C LCD at 0x27 — each screen shows a specific diagnostic state
① Branding
USHER
Edubell
② WiFi Status
WiFi
Connected / Fail
③ IP Address
IP Address
192.168.1.34
④ Internet
Internet
Available / No I
⑤ DNS Status
DNS
OK / Failed
⑥ NTP Status
Time Sync
Success / Failed
⑦ Live Clock
Current Time
08:45:22
⑧ Scheduler Mode
Mode
Live Sync / Offl
⑨ Next Bell
Next Bell
09:00 Assembly
⑩ Error Code
Error
DNS_FAIL / NTP_F
Troubleshooting Guide — Router vs Mobile Hotspot
WIFI_FAIL

Device cannot join the network. Check SSID/password. Ensure 2.4 GHz band is used (ESP8266 does NOT support 5 GHz).

IP_FAIL

WiFi joined but no IP assigned. Router DHCP may be full or disabled. Restart router or reserve a DHCP slot.

NO_INTERNET

IP obtained but cannot reach 8.8.8.8:53. Router may have internet outage or firewall blocking all outbound TCP.

DNS_FAIL

Cannot resolve pool.ntp.org. Router DNS may be misconfigured. Try setting DNS to 8.8.8.8 in router settings.

NTP_FAIL

DNS 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.

Hardware & Wiring

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

Required Libraries
Install via Arduino IDE → Tools → Manage Libraries
ArduinoJson

By Benoit Blanchon — v6.x

LiquidCrystal_I2C

By Frank de Brabander — for LCD display

LittleFS

Built-in with ESP8266 board package 3.x

ESP8266WiFi / HTTPClient

Built-in with ESP8266 board package

Edubell Firmware v7.0 — Power-Cut Recovery
Copy or download — update DEVICE_CODE and DEVICE_SECRET only
/*
 * 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'>&#9888; 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 &amp; 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);
}
Upload Instructions
  1. 1Install Arduino IDE. Add ESP8266 board: Preferences → Additional Boards Manager URLs → http://arduino.esp8266.com/stable/package_esp8266com_index.json
  2. 2Install ArduinoJson v6.x and LiquidCrystal_I2C from Library Manager
  3. 3Download .ino file — update DEVICE_CODE and DEVICE_SECRET near the top
  4. 4Tools → Board → NodeMCU 1.0 (ESP-12E)
  5. 5Tools → Flash Size → 4MB (FS:2MB OTA:~1019KB) ← REQUIRED for LittleFS
  6. 6Connect NodeMCU via USB, select correct COM port, click Upload
  7. 7Open Serial Monitor at 115200 baud — watch the diagnostic sequence output
  8. 8If DNS_FAIL or NTP_FAIL appears: ask your IT admin to open UDP port 123 on the router