/*
* IoT-Based Prepaid Smart Energy Monitoring & Automatic Load Control System
* Author : Ibeto Ifechukwu Stanley
* Matric : 21/ENG05/027
* School : Afe Babalola University, Ado-Ekiti
* Hardware: ESP32-DEVKITC, PZEM-004T, 4-ch Relay, DS3231 RTC,
* 16x2 I2C LCD, 6 LEDs, Buzzer, AMS1117-3.3V
* Tariff : Nigerian NERC Band B — ₦63/kWh
*/
#include <Arduino.h>
#include <Wire.h>
#include <LiquidCrystal_I2C.h>
#include <PZEM004Tv30.h>
#include <RTClib.h>
#include <WiFi.h>
#include <WebServer.h>
#include <Preferences.h>
// ─── Wi-Fi credentials ────────────────────────────────────────────────────────
const char* WIFI_SSID = "Wokwi-GUEST";
const char* WIFI_PASSWORD = "";
// ─── Tariff ───────────────────────────────────────────────────────────────────
const float TARIFF_NGN_PER_KWH = 63.0; // NERC Band B ₦/kWh
const float LOW_BALANCE_THRESHOLD = 200.0; // ₦ — warn user
// ─── Pin definitions ─────────────────────────────────────────────────────────
// PZEM-004T UART (Software serial via HardwareSerial2)
#define PZEM_RX_PIN 16 // IO16 → PZEM TX
#define PZEM_TX_PIN 17 // IO17 → PZEM RX
// Relay pins (active LOW — relay energises when pin is LOW)
#define RELAY1_PIN 19
#define RELAY2_PIN 18
#define RELAY3_PIN 5
#define RELAY4_PIN 23
// LED pins
#define LED_RELAY1 13 // Green — Load 1 ON
#define LED_RELAY2 12 // Green — Load 2 ON
#define LED_RELAY3 14 // Green — Load 3 ON
#define LED_RELAY4 27 // Green — Load 4 ON
#define LED_LOWBAL 26 // Yellow — Low balance
#define LED_FAULT 25 // Red — System fault / zero balance
// Buzzer
#define BUZZER_PIN 33
// I2C for LCD and DS3231 (default SDA=21, SCL=22 on ESP32)
#define I2C_SDA 21
#define I2C_SCL 22
// ─── Objects ─────────────────────────────────────────────────────────────────
LiquidCrystal_I2C lcd(0x27, 16, 2);
PZEM004Tv30 pzem(Serial2, PZEM_RX_PIN, PZEM_TX_PIN);
RTC_DS3231 rtc;
WebServer server(80);
Preferences prefs;
// ─── State ───────────────────────────────────────────────────────────────────
float prepaidBalance = 1000.0; // ₦ — initial credit loaded
float prevEnergyKWh = 0.0; // last known PZEM energy accumulator
bool relayState[4] = {true, true, true, true}; // true = ON
bool loadsEnabled = true; // master flag (false = balance exhausted)
// Live sensor readings
float voltage = 0;
float current = 0;
float power = 0;
float energy = 0;
float frequency = 0;
float pf = 0;
unsigned long lastReadMs = 0;
unsigned long lastLcdMs = 0;
unsigned long lastBuzzMs = 0;
int lcdPage = 0;
bool buzzActive = false;
int buzzCount = 0;
// ─── Helpers ─────────────────────────────────────────────────────────────────
void setRelay(int index, bool on) {
int pins[4] = {RELAY1_PIN, RELAY2_PIN, RELAY3_PIN, RELAY4_PIN};
int leds[4] = {LED_RELAY1, LED_RELAY2, LED_RELAY3, LED_RELAY4};
relayState[index] = on;
digitalWrite(pins[index], on ? LOW : HIGH); // active-LOW relay
digitalWrite(leds[index], on ? HIGH : LOW);
}
void setAllRelays(bool on) {
for (int i = 0; i < 4; i++) setRelay(i, on);
}
void saveBalance() {
prefs.begin("meter", false);
prefs.putFloat("balance", prepaidBalance);
prefs.end();
}
void loadBalance() {
prefs.begin("meter", true);
prepaidBalance = prefs.getFloat("balance", 1000.0);
prefs.end();
}
void buzzShort() {
digitalWrite(BUZZER_PIN, HIGH);
delay(100);
digitalWrite(BUZZER_PIN, LOW);
}
void buzzLong() {
digitalWrite(BUZZER_PIN, HIGH);
delay(500);
digitalWrite(BUZZER_PIN, LOW);
}
// ─── Web Server HTML ──────────────────────────────────────────────────────────
String buildHTML() {
DateTime now = rtc.now();
char timeStr[20];
snprintf(timeStr, sizeof(timeStr), "%02d/%02d/%04d %02d:%02d:%02d",
now.day(), now.month(), now.year(),
now.hour(), now.minute(), now.second());
String relayRows = "";
for (int i = 0; i < 4; i++) {
String status = relayState[i] ? "<span style='color:#27ae60'>ON</span>" : "<span style='color:#e74c3c'>OFF</span>";
relayRows += "<tr><td>Load " + String(i+1) + "</td><td>" + status + "</td>"
+ "<td><a href='/relay?id=" + String(i) + "&state=1'><button>ON</button></a> "
+ "<a href='/relay?id=" + String(i) + "&state=0'><button>OFF</button></a></td></tr>";
}
String html = R"rawhtml(
<!DOCTYPE html><html><head>
<meta charset='UTF-8'>
<meta name='viewport' content='width=device-width,initial-scale=1'>
<meta http-equiv='refresh' content='5'>
<title>Prepaid Energy Meter</title>
<style>
body{font-family:Arial,sans-serif;margin:0;padding:0;background:#f0f2f5}
header{background:#1a73e8;color:#fff;padding:16px 24px}
header h1{margin:0;font-size:20px}
header p{margin:4px 0 0;font-size:13px;opacity:.8}
.grid{display:flex;flex-wrap:wrap;gap:16px;padding:20px 24px}
.card{background:#fff;border-radius:10px;padding:16px 20px;flex:1;min-width:160px;box-shadow:0 1px 4px rgba(0,0,0,.08)}
.card .label{font-size:12px;color:#888;margin-bottom:4px}
.card .value{font-size:26px;font-weight:600;color:#1a1a2e}
.card .unit{font-size:13px;color:#888;margin-top:2px}
.balance-card .value{color:#27ae60}
.warn .value{color:#e67e22}
.danger .value{color:#e74c3c}
table{border-collapse:collapse;width:100%;margin:0 24px 24px;max-width:600px}
th,td{padding:10px 14px;text-align:left;border-bottom:1px solid #eee;font-size:14px}
th{background:#f8f9fa;font-weight:600}
button{background:#1a73e8;color:#fff;border:none;padding:6px 14px;border-radius:6px;cursor:pointer;font-size:13px}
.topup{padding:0 24px 24px}
.topup input{padding:8px 12px;border:1px solid #ddd;border-radius:6px;width:140px;font-size:14px;margin-right:8px}
.topup button{padding:8px 18px}
.ts{font-size:12px;color:#aaa;padding:0 24px 16px}
</style></head><body>
<header>
<h1>IoT Prepaid Smart Energy Meter</h1>
<p>Afe Babalola University — Mechatronics Engineering FYP</p>
</header>
<div class='grid'>
<div class='card balance-card)rawhtml";
if (prepaidBalance < LOW_BALANCE_THRESHOLD) html += " warn";
if (prepaidBalance <= 0) html += " danger";
html += R"rawhtml('>
<div class='label'>Prepaid Balance</div>
<div class='value'>₦)rawhtml";
html += String(prepaidBalance, 2);
html += R"rawhtml(</div>
<div class='unit'>Nigerian Naira (Band B ₦63/kWh)</div>
</div>
<div class='card'>
<div class='label'>Voltage</div>
<div class='value'>)rawhtml";
html += String(voltage, 1);
html += R"rawhtml(</div><div class='unit'>Volts AC</div></div>
<div class='card'>
<div class='label'>Current</div>
<div class='value'>)rawhtml";
html += String(current, 3);
html += R"rawhtml(</div><div class='unit'>Amperes</div></div>
<div class='card'>
<div class='label'>Active Power</div>
<div class='value'>)rawhtml";
html += String(power, 1);
html += R"rawhtml(</div><div class='unit'>Watts</div></div>
<div class='card'>
<div class='label'>Energy (session)</div>
<div class='value'>)rawhtml";
html += String(energy, 3);
html += R"rawhtml(</div><div class='unit'>kWh</div></div>
<div class='card'>
<div class='label'>Frequency</div>
<div class='value'>)rawhtml";
html += String(frequency, 1);
html += R"rawhtml(</div><div class='unit'>Hz</div></div>
<div class='card'>
<div class='label'>Power Factor</div>
<div class='value'>)rawhtml";
html += String(pf, 2);
html += R"rawhtml(</div><div class='unit'>cosφ</div></div>
</div>
<div class='topup'>
<strong>Top-up balance:</strong><br><br>
<form action='/topup' method='GET'>
<input type='number' name='amount' min='100' max='50000' placeholder='Amount (₦)'>
<button type='submit'>Recharge</button>
</form>
</div>
<table>
<tr><th>Load</th><th>Status</th><th>Control</th></tr>
)rawhtml";
html += relayRows;
html += R"rawhtml(
</table>
<p class='ts'>Last updated: )rawhtml";
html += String(timeStr);
html += " — Auto-refresh every 5s</p></body></html>";
return html;
}
// ─── Web routes ───────────────────────────────────────────────────────────────
void handleRoot() {
server.send(200, "text/html", buildHTML());
}
void handleRelay() {
if (!server.hasArg("id") || !server.hasArg("state")) {
server.send(400, "text/plain", "Missing args"); return;
}
int id = server.arg("id").toInt();
bool state = server.arg("state").toInt() == 1;
if (id < 0 || id > 3) { server.send(400, "text/plain", "Invalid id"); return; }
if (!loadsEnabled && state) {
server.send(403, "text/plain", "Balance exhausted — recharge first"); return;
}
setRelay(id, state);
server.sendHeader("Location", "/");
server.send(302, "text/plain", "");
}
void handleTopup() {
if (!server.hasArg("amount")) { server.send(400, "text/plain", "Missing amount"); return; }
float amount = server.arg("amount").toFloat();
if (amount <= 0) { server.send(400, "text/plain", "Invalid amount"); return; }
prepaidBalance += amount;
saveBalance();
if (prepaidBalance > 0 && !loadsEnabled) {
loadsEnabled = true;
setAllRelays(true);
digitalWrite(LED_FAULT, LOW);
buzzShort();
}
server.sendHeader("Location", "/");
server.send(302, "text/plain", "");
}
void handleAPI() {
String json = "{";
json += "\"voltage\":" + String(voltage, 2) + ",";
json += "\"current\":" + String(current, 3) + ",";
json += "\"power\":" + String(power, 2) + ",";
json += "\"energy\":" + String(energy, 4) + ",";
json += "\"frequency\":" + String(frequency, 1) + ",";
json += "\"pf\":" + String(pf, 2) + ",";
json += "\"balance\":" + String(prepaidBalance, 2) + ",";
json += "\"loads_on\":" + String(loadsEnabled ? "true" : "false");
json += "}";
server.send(200, "application/json", json);
}
// ─── LCD display cycling ─────────────────────────────────────────────────────
void updateLCD() {
lcd.clear();
switch (lcdPage) {
case 0:
lcd.setCursor(0,0); lcd.print("V:");
lcd.print(voltage, 1); lcd.print("V");
lcd.setCursor(9,0); lcd.print("I:");
lcd.print(current, 2); lcd.print("A");
lcd.setCursor(0,1); lcd.print("P:");
lcd.print(power, 1); lcd.print("W");
lcd.setCursor(9,1); lcd.print("PF:");
lcd.print(pf, 2);
break;
case 1:
lcd.setCursor(0,0); lcd.print("Energy:");
lcd.print(energy, 3); lcd.print("kWh");
lcd.setCursor(0,1); lcd.print("Freq:");
lcd.print(frequency, 1); lcd.print("Hz");
break;
case 2:
lcd.setCursor(0,0); lcd.print("Balance:");
lcd.setCursor(0,1); lcd.print((char)0xA5); // ₦ approximation
lcd.print(prepaidBalance, 2);
if (prepaidBalance < LOW_BALANCE_THRESHOLD) {
lcd.setCursor(12,1); lcd.print("LOW!");
}
break;
case 3: {
lcd.setCursor(0,0); lcd.print("Loads:");
String states = "";
for (int i = 0; i < 4; i++) states += (relayState[i] ? "1" : "0");
lcd.print(states);
DateTime now = rtc.now();
lcd.setCursor(0,1);
char buf[17];
snprintf(buf, sizeof(buf), "%02d/%02d %02d:%02d:%02d",
now.day(), now.month(), now.hour(), now.minute(), now.second());
lcd.print(buf);
break;
}
}
lcdPage = (lcdPage + 1) % 4;
}
// ─── Setup ────────────────────────────────────────────────────────────────────
void setup() {
Serial.begin(115200);
// Relay pins
int relayPins[4] = {RELAY1_PIN, RELAY2_PIN, RELAY3_PIN, RELAY4_PIN};
for (int p : relayPins) { pinMode(p, OUTPUT); digitalWrite(p, HIGH); } // OFF initially
// LED pins
int ledPins[6] = {LED_RELAY1, LED_RELAY2, LED_RELAY3, LED_RELAY4, LED_LOWBAL, LED_FAULT};
for (int p : ledPins) { pinMode(p, OUTPUT); digitalWrite(p, LOW); }
// Buzzer
pinMode(BUZZER_PIN, OUTPUT);
digitalWrite(BUZZER_PIN, LOW);
// I2C
Wire.begin(I2C_SDA, I2C_SCL);
// LCD
lcd.init();
lcd.backlight();
lcd.setCursor(0,0); lcd.print("Prepaid Meter");
lcd.setCursor(0,1); lcd.print("ABUAD FYP 2026");
delay(2000);
lcd.clear();
// RTC
if (!rtc.begin()) {
Serial.println("[RTC] Not found — check wiring");
lcd.print("RTC ERROR"); delay(2000);
}
if (rtc.lostPower()) {
rtc.adjust(DateTime(F(__DATE__), F(__TIME__)));
}
// PZEM
Serial2.begin(9600, SERIAL_8N1, PZEM_RX_PIN, PZEM_TX_PIN);
delay(500);
// Load saved balance from NVS
loadBalance();
Serial.printf("[Balance] Loaded: %.2f NGN\n", prepaidBalance);
// Wi-Fi
lcd.clear();
lcd.setCursor(0,0); lcd.print("Connecting WiFi");
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
int tries = 0;
while (WiFi.status() != WL_CONNECTED && tries < 20) {
delay(500); Serial.print("."); tries++;
}
if (WiFi.status() == WL_CONNECTED) {
Serial.printf("\n[WiFi] Connected: %s\n", WiFi.localIP().toString().c_str());
lcd.clear();
lcd.setCursor(0,0); lcd.print("IP:");
lcd.setCursor(0,1); lcd.print(WiFi.localIP().toString());
delay(2000);
} else {
Serial.println("\n[WiFi] Failed — running offline");
lcd.clear();
lcd.setCursor(0,0); lcd.print("WiFi OFFLINE");
delay(2000);
}
// Web server routes
server.on("/", handleRoot);
server.on("/relay", handleRelay);
server.on("/topup", handleTopup);
server.on("/api", handleAPI);
server.begin();
Serial.println("[Server] HTTP started on port 80");
// Turn on all relays — system ready
setAllRelays(true);
buzzShort(); buzzShort();
lcd.clear();
lcd.setCursor(0,0); lcd.print("System READY");
lcd.setCursor(0,1); lcd.print("Monitoring...");
delay(1500);
}
// ─── Loop ─────────────────────────────────────────────────────────────────────
void loop() {
server.handleClient();
unsigned long now = millis();
// ── Read PZEM every 2 seconds ─────────────────────────────────────────────
if (now - lastReadMs >= 2000) {
lastReadMs = now;
float v = pzem.voltage();
float i = pzem.current();
float p = pzem.power();
float e = pzem.energy(); // cumulative kWh from PZEM
float f = pzem.frequency();
float pf_= pzem.pf();
bool valid = !isnan(v) && !isnan(i) && !isnan(p) && !isnan(e);
if (valid) {
voltage = v;
current = i;
power = p;
frequency = isnan(f) ? frequency : f;
pf = isnan(pf_)? pf : pf_;
// ── Deduct prepaid balance based on energy delta ──────────────────────
if (prevEnergyKWh == 0.0) {
prevEnergyKWh = e; // first read — seed the baseline
} else {
float deltaKWh = e - prevEnergyKWh;
if (deltaKWh > 0.0 && deltaKWh < 1.0) { // sanity guard
float cost = deltaKWh * TARIFF_NGN_PER_KWH;
prepaidBalance -= cost;
prevEnergyKWh = e;
if (prepaidBalance < 0) prepaidBalance = 0;
saveBalance();
}
}
energy = e; // expose for display/API
Serial.printf("[PZEM] %.1fV | %.3fA | %.1fW | %.4fkWh | %.1fHz | PF%.2f | Bal:₦%.2f\n",
voltage, current, power, energy, frequency, pf, prepaidBalance);
} else {
Serial.println("[PZEM] Read error — check sensor wiring");
}
// ── Balance checks ────────────────────────────────────────────────────────
if (prepaidBalance <= 0 && loadsEnabled) {
loadsEnabled = false;
setAllRelays(false);
digitalWrite(LED_FAULT, HIGH);
digitalWrite(LED_LOWBAL, LOW);
buzzLong(); delay(200); buzzLong();
lcd.clear();
lcd.setCursor(0,0); lcd.print("BALANCE ZERO!");
lcd.setCursor(0,1); lcd.print("Loads OFF");
Serial.println("[ALERT] Balance exhausted — all loads disconnected");
} else if (prepaidBalance < LOW_BALANCE_THRESHOLD && prepaidBalance > 0) {
digitalWrite(LED_LOWBAL, HIGH);
} else {
digitalWrite(LED_LOWBAL, LOW);
}
}
// ── Update LCD every 4 seconds, cycling pages ────────────────────────────
if (now - lastLcdMs >= 4000) {
lastLcdMs = now;
if (loadsEnabled) updateLCD();
}
// ── Low-balance buzzer beep every 30 seconds ─────────────────────────────
if (prepaidBalance < LOW_BALANCE_THRESHOLD && prepaidBalance > 0 && loadsEnabled) {
if (now - lastBuzzMs >= 30000) {
lastBuzzMs = now;
buzzShort();
}
}
}