In this project, we build a long-range IoT energy monitoring system using ESP32, LoRa, and a custom web dashboard. It measures voltage, current, power, and energy in real time and allows remote control of loads.
Whether you want to monitor a remote farm, a large hostel, or just learn how wireless energy monitoring works, this project is perfect for you.
What You Will LearnBy completing this project, you'll understand:
- LoRa Communication: How to send data wirelessly over kilometers without Wi-Fi.
- Energy Monitoring: How to safely measure Voltage, Current, Power, and Frequency using the PZEM004T sensor.
- Web Servers on ESP32: How to host a website directly on a microcontroller.
- Real-Time Data: How to sync time using NTP servers for accurate billing.
- Remote Control: How to switch appliances ON/OFF remotely via the web dashboard.
This project involves measuring mains electricity.
- Always disconnect mains power before wiring the PZEM module
- The PZEM004T module is designed to be safer than direct connections, but it still interfaces with high voltage.
- Never touch exposed wires while the system is powered.
- Use a proper enclosure (plastic box) to hide all connections.
- If you are unsure about working with mains electricity, ask for help from an adult or a qualified electrician.
- For testing, you can power the ESP32 via USB and simulate data before connecting to high voltage.
This system also supports remote relay control and automatic billing calculation, making it useful for real-world monitoring setups
Sponsored by NextPCB
This project was successfully completed with the support of NextPCB, a reliable multilayer PCB manufacturer. With over 15 years of experience in PCB fabrication and assembly, NextPCB offers high-quality and dependable PCB solutions for makers and professionals worldwide.
You can order high-quality PCBs starting at $1.9, and multilayer PCBs starting at $6.9:
https://www.nextpcb.com/pcb-quote
Get free PCB assembly for up to 5 boards:
https://www.nextpcb.com/pcb-assembly-quote
With comprehensive Design for Manufacture (DFM) analysis features, HQDFM is a free, sophisticated online PCB Gerber file viewer.
SuppliesRequired Material
- ESP32 Development Board Γ2 (Transmitter + Receiver) (Amazon.com)
- LoRa SX1278 Module Γ2 (Amazon.com)
- PZEM004T v3.0 Energy Meter Module Γ1 (Amazon.com)
- ST7735 TFT Display Γ1 (Amazon.com)
- SSD1306 OLED Display Γ1 (Amazon.com)
- Relay Module Γ1 (Amazon.com)
- Push Button Γ1 (Amazon.com)
- Jumper Wires (Amazon.com)
- Breadboard (optional)
- 5V Power Supply (Amazon.com)
Tools
- Soldering Iron and Solder (Amazon.com)
- 3D Printer (Amazon.com)
- Screwdriver Set (Amazon.com)
- Computer with Arduino IDE installed
- USB Cable (for programming ESP32)
For the box, you will need:
- The files attached to this step
- 3D printer
- Filament: PLA recommended
- Slicing software
This project consists of two ESP32-based units: a transmitter (meter unit) and a receiver (monitoring unit). The transmitter measures electrical parameters using a PZEM004T energy meter module and sends the data wirelessly using LoRa.
The receiver collects this data, displays it locally, and publishes it to a web dashboard for monitoring and control.
Before we start wiring, let's understand the flow. This system has two main parts:
- The Transmitter (Meter Unit):
- It reads electricity data using the PZEM004T.
- It shows the data on a TFT Screen.
- It sends the data wirelessly using LoRa.
- The Receiver (Monitoring Unit):
- It catches the LoRa signal.
- It shows connection status on an OLED Screen.
- It hosts a Web Dashboard so you can check data from your phone or laptop.
Data Flow:
PZEM Sensor β ESP32 Transmitter β LoRa Radio β ESP32 Receiver β Web Dashboard
Step 2: Wiring the TransmitterLet's build the unit that measures the energy. We need to connect the ESP32 to the Sensor, LoRa module, and Display.
β’ PZEM TX β ESP32 GPIO16
β’ PZEM RX β ESP32 GPIO17
This module measures voltage, current, power, frequency, and energy consumption.
πΉ LoRa Module Connectionsβ’ NSS β GPIO15
β’ RST β GPIO14
β’ DIO0 β GPIO26
β’ MOSI β GPIO23
β’ MISO β GPIO19
β’ SCK β GPIO18
These pins are used for SPI communication.
πΉ TFT Display Connections (ST7735)β’ CS β GPIO5
β’ DC β GPIO21
β’ RST β GPIO22
This display shows live readings and system status.
πΉ Button and Relayβ’ Button β GPIO4 (used for page switch + reset)
β’ Relay β GPIO2 (used for load control)
Note: Connect all GND pins together and all 5V/3.3V pins according to module requirements.
Step 3: Wiring the ReceiverNow, let's build the unit that receives the data and shows you the website.
Follow the wiring carefully based on the pin configuration used in the code.
πΉ LoRa Module Connections (ESP32 Receiver)β’ NSS β GPIO15
β’ RST β GPIO14
β’ DIO0 β GPIO13
β’ MOSI β GPIO10
β’ MISO β GPIO11
β’ SCK β GPIO12
These pins handle SPI communication for LoRa data reception.
πΉ OLED Display Connections (SSD1306)β’ SDA β GPIO17
β’ SCL β GPIO18
The OLED shows system status such as IP address, signal strength, and connection status.
Make sure:
β’ Both ESP32 and LoRa module share common GND
β’ Use stable 5V supply for reliable operation
Do NOT power the circuit while wiring.
Step 4: Assemble the SetupAssemble the transmitter and receiver using the wiring diagram, connecting each module step-by-step and testing individually before final assembly.
In both units, the ESP32 works as the main controller for sensing, communication, and control. The PZEM004T measures voltage, current, power, and energy, while the LoRa modules handle long-range data transfer.
The transmitter shows live readings on the TFT display, and the receiver shows status (IP, signal strength) on the OLED and hosts the web dashboard for monitoring and relay control.
Step 5: Soldering the Circuit Onto PerfboardSolder all modules carefully onto the perfboard one at a time. Working step-by-step helps avoid mistakes and makes troubleshooting easier.
Start with the smaller and lower-profile components first, such as:
- PZEM connection headers
- Button
- Small connectors or pin headers
Next, solder the ESP32 headers and supporting components. After that, solder the LoRa module connections carefully according to the wiring layout.
Make sure:
- All pins are aligned correctly before soldering
- Modules sit flat on the board
- No pins are shorted together
Incorrect alignment can damage components or prevent the system from working properly.
Continuity Testing
After soldering each section, check connections using a multimeter in continuity mode.
Steps:
- Place one probe on the component pin
- Place the other probe on the corresponding connection point
If you hear a beep or see near-zero resistance, the connection is correct.
This step helps identify loose joints, broken traces, or accidental shorts before powering the circuit.
Step 6: Transmitter CodeYou don't need to be a coding expert, but understanding what the code does helps with troubleshooting. The code is divided into logical sections:
WiFi & Time Sync:
The ESP32 tries to connect to WiFi to get the exact time from an NTP server. This ensures your energy bills are timestamped correctly. If WiFi fails, the meter still works, but the clock won't sync.
Sensor Reading:
It constantly asks the PZEM004T for Voltage, Current, Power, and Frequency. It includes "noise filtering" to ignore sudden spikes that aren't real.
Energy Tracking:
It calculates how much energy you've used today, this month, and total lifetime. This data is saved in the ESP32's memory so you don't lose it if power goes out.
Display System:
- Page 1: Shows live Voltage/Current.
- Page 2: Shows total consumption and time.
- Button:Short press changes pages; Long press resets consumption.
LoRa Transmission:
- Every few seconds, it packs all this data into a message and shoots it over the air to the receiver.
#include <Arduino.h>
#include <SPI.h>
#include <WiFi.h>
#include <time.h>
#include <Adafruit_GFX.h>
#include <Adafruit_ST7735.h>
#include <LoRa.h>
#include <PZEM004Tv30.h>
#include <Preferences.h>
// βββββββββββββββββββββββββββββββββββββββββββββ
// WiFi / NTP CREDENTIALS β edit before flash
// βββββββββββββββββββββββββββββββββββββββββββββ
#define WIFI_SSID "ESP"
#define WIFI_PASS "abcd1234"
#define NTP_SERVER1 "pool.ntp.org"
#define NTP_SERVER2 "time.google.com"
#define NTP_TZ "UTC+5:30" // POSIX tz string β adjust for your region
// e.g. "WIB-7" for Jakarta, "EST5EDT,M3.2.0,M11.1.0" for US East
// βββββββββββββββββββββββββββββββββββββββββββββ
// PIN DEFINITIONS (UNCHANGED)
// βββββββββββββββββββββββββββββββββββββββββββββ
#define TFT_CS 5
#define TFT_RST 22
#define TFT_DC 21
#define LORA_SCK 18
#define LORA_MOSI 23
#define LORA_MISO 19
#define LORA_NSS 15
#define LORA_RST 14
#define LORA_DIO0 26
#define PZEM_RX 16
#define PZEM_TX 17
#define BUTTON_PIN 4
#define RELAY_PIN 2
// βββββββββββββββββββββββββββββββββββββββββββββ
// SYSTEM CONSTANTS (UNCHANGED except DISPLAY_MS)
// βββββββββββββββββββββββββββββββββββββββββββββ
#define DEVICE_ID "1A"
#define LORA_FREQUENCY 433E6
#define LORA_BANDWIDTH 125E3
#define LORA_SPREADING_FACTOR 7
#define LORA_CODING_RATE 5
#define LORA_TX_POWER 17
#define PZEM_WARMUP_MS 3000
#define PZEM_V_MAX 280.0f
#define PZEM_V_MIN 50.0f
#define PZEM_A_MAX 100.0f
#define PZEM_W_MAX 25000.0f
#define PZEM_HZ_MIN 40.0f
#define PZEM_HZ_MAX 70.0f
#define BTN_DEBOUNCE_MS 30
#define BTN_SHORT_MAX_MS 1000
#define BTN_LONG_MIN_MS 3000
#define PZEM_READ_MS 2000
#define LORA_TX_MS 5000
#define DISPLAY_MS 1000 // β raised from 500 ms to 1 s (fix #2)
#define NVS_SAVE_MS 60000UL
#define NVS_NS "energy"
#define NVS_TOTAL "total"
#define NVS_MONTHLY "monthly"
#define NVS_PREVDAY "prevday"
#define NVS_MONTH "month"
#define NVS_DAY "day"
// WiFi connect timeout at boot
#define WIFI_TIMEOUT_MS 10000UL
// βββββββββββββββββββββββββββββββββββββββββββββ
// COLOUR PALETTE (RGB565) (UNCHANGED)
// βββββββββββββββββββββββββββββββββββββββββββββ
#define CLR_BG 0x0000
#define CLR_HDR0 0x2945
#define CLR_HDR1 0x4228
#define CLR_LABEL 0x8C51
#define CLR_VALUE 0xFFFF
#define CLR_GOOD 0x07E0
#define CLR_WARN 0xFFE0
#define CLR_ALERT 0xF800
#define CLR_CYAN 0x07FF
#define CLR_ORANGE 0xFD20
#define CLR_DIM 0x4208
// βββββββββββββββββββββββββββββββββββββββββββββ
// GLOBAL OBJECTS (UNCHANGED)
// βββββββββββββββββββββββββββββββββββββββββββββ
Adafruit_ST7735 tft = Adafruit_ST7735(TFT_CS, TFT_DC, TFT_RST);
PZEM004Tv30 pzem(Serial2, PZEM_RX, PZEM_TX);
Preferences prefs;
// βββββββββββββββββββββββββββββββββββββββββββββ
// LIVE MEASUREMENTS (UNCHANGED)
// βββββββββββββββββββββββββββββββββββββββββββββ
float voltage = 0.0f;
float current = 0.0f;
float power = 0.0f;
float frequency = 0.0f;
float pzemKwh = 0.0f;
bool pzemOk = false;
// βββββββββββββββββββββββββββββββββββββββββββββ
// BILLING COUNTERS (UNCHANGED)
// βββββββββββββββββββββββββββββββββββββββββββββ
float totalKwh = 0.0f;
float monthlyKwh = 0.0f;
float prevDayKwh = 0.0f;
float dailyKwh = 0.0f;
float prevPzemKwh = 0.0f;
bool countersReady = false;
uint32_t dayStartMs = 0;
uint32_t monthStartMs = 0;
// βββββββββββββββββββββββββββββββββββββββββββββ
// RELAY STATE (UNCHANGED)
// βββββββββββββββββββββββββββββββββββββββββββββ
bool relayOn = true;
// βββββββββββββββββββββββββββββββββββββββββββββ
// DISPLAY STATE
// βββββββββββββββββββββββββββββββββββββββββββββ
uint8_t currentPage = 0;
uint8_t lastPage = 255;
// ββ Flash state (fix #3) βββββββββββββββββββββββββββββββββββββββββββββ
// triggerFlash() only sets pending flag; SPI fill happens in handleFlash().
bool flashPending = false; // NEW: deferred fill not yet sent to TFT
bool flashActive = false;
uint32_t flashStart = 0;
uint16_t flashColor = CLR_GOOD;
#define FLASH_MS 150
// ββ Anti-flicker: shadow copies of last drawn values (fix #2) ββββββββ
// drawPage0 compares against these before issuing any SPI writes.
struct P0Shadow {
float voltage = -1.0f;
float current = -1.0f;
float power = -1.0f;
float frequency = -1.0f;
bool relayOn = false;
bool pzemOk = false;
bool warmup = false;
} p0;
struct P1Shadow {
float totalKwh = -1.0f;
char timeBuf[20] = {0};
} p1;
// βββββββββββββββββββββββββββββββββββββββββββββ
// NTP / RTC STATE (NEW β replaces simulated time)
// βββββββββββββββββββββββββββββββββββββββββββββ
bool ntpSynced = false; // true once first successful NTP sync
// βββββββββββββββββββββββββββββββββββββββββββββ
// BUTTON STATE MACHINE (UNCHANGED)
// βββββββββββββββββββββββββββββββββββββββββββββ
enum BtnState { BTN_IDLE, BTN_DEBOUNCE, BTN_HELD, BTN_LONG_DONE, BTN_RELEASE };
BtnState btnState = BTN_IDLE;
uint32_t btnTimer = 0;
uint32_t btnPressTime = 0;
bool btnLongDone = false;
// βββββββββββββββββββββββββββββββββββββββββββββ
// TIMING (UNCHANGED)
// βββββββββββββββββββββββββββββββββββββββββββββ
uint32_t lastPzemRead = 0;
uint32_t lastLoraTx = 0;
uint32_t lastDisplayUpd = 0;
uint32_t lastNvsSave = 0;
// βββββββββββββββββββββββββββββββββββββββββββββ
// PROTOTYPES
// βββββββββββββββββββββββββββββββββββββββββββββ
void initDisplay();
void initLoRa();
void initWiFiNTP();
void loadPreferences();
void savePreferences();
void resetTotalConsumption();
void readPZEM();
void updateCounters();
void setRelay(bool on);
void transmitLoRa();
void receiveLoRa();
void handleButton();
void handleFlash();
void triggerFlash(uint16_t color);
void invalidateShadows();
void drawCurrentPage();
void drawPage0();
void drawPage1();
void drawHdr(const char* title, uint16_t color, const char* pg);
void drawRow(uint8_t y, uint8_t h, const char* label, const char* value,
uint16_t lblColor, uint16_t valColor);
void getRealTime(char* buf, size_t len); // replaces getSimTime()
// βββββββββββββββββββββββββββββββββββββββββββββ
// SETUP
// βββββββββββββββββββββββββββββββββββββββββββββ
void setup() {
Serial.begin(115200);
Serial.println(F("[TX] Postpaid v2.2 booting..."));
pinMode(BUTTON_PIN, INPUT_PULLUP);
pinMode(RELAY_PIN, OUTPUT);
setRelay(true);
loadPreferences();
initDisplay();
// NTP init before LoRa so SPI bus is free during WiFi association
initWiFiNTP();
SPI.begin(LORA_SCK, LORA_MISO, LORA_MOSI, LORA_NSS);
initLoRa();
Serial2.begin(9600, SERIAL_8N1, PZEM_RX, PZEM_TX);
dayStartMs = millis();
monthStartMs = millis();
Serial.println(F("[TX] Ready."));
}
// βββββββββββββββββββββββββββββββββββββββββββββ
// LOOP (UNCHANGED structure)
// βββββββββββββββββββββββββββββββββββββββββββββ
void loop() {
uint32_t now = millis();
handleButton();
handleFlash(); // must run every loop tick for smooth flash timing
if (now - lastPzemRead >= PZEM_READ_MS) {
lastPzemRead = now;
readPZEM();
if (pzemOk) updateCounters();
}
receiveLoRa();
if (now - lastLoraTx >= LORA_TX_MS) {
lastLoraTx = now;
transmitLoRa();
}
// Suppress TFT update while flash is in progress (fix #3)
if (!flashActive && !flashPending && (now - lastDisplayUpd >= DISPLAY_MS)) {
lastDisplayUpd = now;
drawCurrentPage();
}
if (now - lastNvsSave >= NVS_SAVE_MS) {
lastNvsSave = now;
savePreferences();
}
}
// βββββββββββββββββββββββββββββββββββββββββββββ
// INIT: DISPLAY (UNCHANGED)
// βββββββββββββββββββββββββββββββββββββββββββββ
void initDisplay() {
tft.initR(INITR_BLACKTAB);
tft.setRotation(1);
tft.fillScreen(CLR_BG);
tft.setTextWrap(false);
tft.setTextColor(CLR_CYAN);
tft.setTextSize(2);
tft.setCursor(6, 20); tft.print(F("POSTPAID"));
tft.setCursor(6, 44); tft.print(F("METER"));
tft.setTextSize(1);
tft.setTextColor(CLR_LABEL);
tft.setCursor(6, 74); tft.print(F("Device: " DEVICE_ID));
tft.setCursor(6, 90); tft.print(F("v2.2 β Init..."));
delay(1500);
tft.fillScreen(CLR_BG);
}
// βββββββββββββββββββββββββββββββββββββββββββββ
// INIT: WiFi + NTP (NEW)
//
// Strategy:
// - Connect to WiFi with a 10 s timeout.
// - If connected, call configTime() and wait up to 5 s for a valid epoch.
// - If WiFi or NTP fails we continue without real time (display shows
// "No NTP" until sync succeeds β the loop does NOT retry; the RTC
// will have a valid time once it syncs even if WiFi later drops).
// - WiFi is NOT disconnected after sync so the internal RTC stays
// driven by the hardware timer (no dependency on WiFi staying up).
// βββββββββββββββββββββββββββββββββββββββββββββ
void initWiFiNTP() {
tft.fillScreen(CLR_BG);
tft.setTextSize(1);
tft.setTextColor(CLR_LABEL);
tft.setCursor(4, 20); tft.print(F("Connecting WiFi..."));
tft.setCursor(4, 32); tft.print(WIFI_SSID);
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASS);
uint32_t t0 = millis();
while (WiFi.status() != WL_CONNECTED && millis() - t0 < WIFI_TIMEOUT_MS) {
delay(250);
}
if (WiFi.status() != WL_CONNECTED) {
Serial.println(F("[WiFi] Not connected β continuing without NTP."));
tft.setTextColor(CLR_WARN);
tft.setCursor(4, 48); tft.print(F("WiFi failed β no NTP"));
delay(1500);
tft.fillScreen(CLR_BG);
return;
}
Serial.printf("[WiFi] Connected: %s\n", WiFi.localIP().toString().c_str());
tft.setTextColor(CLR_GOOD);
tft.setCursor(4, 48); tft.print(F("WiFi OK β syncing NTP..."));
// Configure SNTP β uses ESP32 Arduino core's built-in SNTP client
configTime(0, 0, NTP_SERVER1, NTP_SERVER2);
setenv("TZ", NTP_TZ, 1);
tzset();
// Wait up to 5 s for a valid time
struct tm ti;
uint32_t t1 = millis();
bool got = false;
while (millis() - t1 < 5000) {
if (getLocalTime(&ti) && ti.tm_year > 100) { got = true; break; }
delay(200);
}
if (got) {
ntpSynced = true;
Serial.printf("[NTP] Synced: %04d-%02d-%02d %02d:%02d:%02d\n",
ti.tm_year + 1900, ti.tm_mon + 1, ti.tm_mday,
ti.tm_hour, ti.tm_min, ti.tm_sec);
tft.setTextColor(CLR_GOOD);
tft.setCursor(4, 60); tft.print(F("NTP OK"));
} else {
Serial.println(F("[NTP] Sync timeout."));
tft.setTextColor(CLR_WARN);
tft.setCursor(4, 60); tft.print(F("NTP timeout"));
}
delay(1000);
tft.fillScreen(CLR_BG);
}
// βββββββββββββββββββββββββββββββββββββββββββββ
// INIT: LoRa (UNCHANGED)
// βββββββββββββββββββββββββββββββββββββββββββββ
void initLoRa() {
LoRa.setPins(LORA_NSS, LORA_RST, LORA_DIO0);
uint8_t retries = 5;
while (!LoRa.begin(LORA_FREQUENCY) && retries--) {
Serial.println(F("[LoRa] Retry..."));
delay(500);
}
if (retries == 0) {
Serial.println(F("[LoRa] FATAL"));
tft.fillScreen(CLR_ALERT);
tft.setCursor(4, 56); tft.setTextColor(CLR_VALUE);
tft.setTextSize(1); tft.print(F("LoRa INIT FAILED"));
while (true) delay(1000);
}
LoRa.setSpreadingFactor(LORA_SPREADING_FACTOR);
LoRa.setSignalBandwidth(LORA_BANDWIDTH);
LoRa.setCodingRate4(LORA_CODING_RATE);
LoRa.setTxPower(LORA_TX_POWER);
LoRa.enableCrc();
Serial.println(F("[LoRa] OK."));
}
// βββββββββββββββββββββββββββββββββββββββββββββ
// NVS (UNCHANGED)
// βββββββββββββββββββββββββββββββββββββββββββββ
void loadPreferences() {
prefs.begin(NVS_NS, true);
totalKwh = prefs.getFloat(NVS_TOTAL, 0.0f);
monthlyKwh = prefs.getFloat(NVS_MONTHLY, 0.0f);
prevDayKwh = prefs.getFloat(NVS_PREVDAY, 0.0f);
prefs.end();
dailyKwh = 0.0f;
Serial.printf("[NVS] total=%.3f monthly=%.3f prevday=%.3f\n",
totalKwh, monthlyKwh, prevDayKwh);
}
void savePreferences() {
if (totalKwh == 0.0f && monthlyKwh == 0.0f) return;
prefs.begin(NVS_NS, false);
prefs.putFloat(NVS_TOTAL, totalKwh);
prefs.putFloat(NVS_MONTHLY, monthlyKwh);
prefs.putFloat(NVS_PREVDAY, prevDayKwh);
prefs.end();
Serial.println(F("[NVS] Saved."));
}
// βββββββββββββββββββββββββββββββββββββββββββββ
// RESET TOTAL CONSUMPTION (UNCHANGED)
// βββββββββββββββββββββββββββββββββββββββββββββ
void resetTotalConsumption() {
totalKwh = 0.0f;
monthlyKwh = 0.0f;
prevDayKwh = 0.0f;
dailyKwh = 0.0f;
prevPzemKwh = pzemKwh;
prefs.begin(NVS_NS, false);
prefs.putFloat(NVS_TOTAL, 0.0f);
prefs.putFloat(NVS_MONTHLY, 0.0f);
prefs.putFloat(NVS_PREVDAY, 0.0f);
prefs.end();
Serial.println(F("[RESET] Total consumption cleared."));
}
// βββββββββββββββββββββββββββββββββββββββββββββ
// PZEM READ + SPIKE FILTER (UNCHANGED)
// βββββββββββββββββββββββββββββββββββββββββββββ
void readPZEM() {
float v = pzem.voltage();
float i = pzem.current();
float p = pzem.power();
float f = pzem.frequency();
float e = pzem.energy();
if (millis() < PZEM_WARMUP_MS) {
pzemOk = false;
Serial.println(F("[PZEM] Warmup β skipping."));
return;
}
if (isnan(v) || isnan(i) || isnan(p) || isnan(f) || isnan(e)) {
pzemOk = false;
Serial.println(F("[PZEM] Read failed β NaN."));
return;
}
bool sane = (v >= PZEM_V_MIN && v <= PZEM_V_MAX) &&
(i >= 0.0f && i <= PZEM_A_MAX) &&
(p >= 0.0f && p <= PZEM_W_MAX) &&
(f >= PZEM_HZ_MIN && f <= PZEM_HZ_MAX);
if (!sane) {
pzemOk = false;
Serial.printf("[PZEM] Spike rejected: V=%.1f A=%.1f W=%.1f Hz=%.1f\n",
v, i, p, f);
return;
}
pzemOk = true;
voltage = v;
current = i;
power = p;
frequency = f;
pzemKwh = e;
if (!countersReady) {
prevPzemKwh = pzemKwh;
countersReady = true;
dayStartMs = millis();
monthStartMs = millis();
Serial.printf("[PZEM] First valid read β baseline anchored at %.4f kWh\n",
prevPzemKwh);
}
Serial.printf("[PZEM] V=%.1f A=%.3f W=%.1f Hz=%.1f E=%.4f\n",
voltage, current, power, frequency, pzemKwh);
}
// βββββββββββββββββββββββββββββββββββββββββββββ
// ENERGY COUNTER UPDATE (UNCHANGED)
// βββββββββββββββββββββββββββββββββββββββββββββ
void updateCounters() {
if (!countersReady) return;
uint32_t now = millis();
float delta = pzemKwh - prevPzemKwh;
if (delta < 0.0f) delta = 0.0f;
prevPzemKwh = pzemKwh;
totalKwh += delta;
monthlyKwh += delta;
dailyKwh += delta;
if ((now - dayStartMs) >= 86400000UL) {
dayStartMs = now;
prevDayKwh = dailyKwh;
dailyKwh = 0.0f;
Serial.printf("[COUNTER] Day rollover β yesterday=%.4f kWh\n", prevDayKwh);
savePreferences();
}
if ((now - monthStartMs) >= (30UL * 86400000UL)) {
monthStartMs = now;
monthlyKwh = 0.0f;
Serial.println(F("[COUNTER] Month rollover."));
savePreferences();
}
}
// βββββββββββββββββββββββββββββββββββββββββββββ
// RELAY CONTROL (UNCHANGED)
// βββββββββββββββββββββββββββββββββββββββββββββ
void setRelay(bool on) {
relayOn = on;
digitalWrite(RELAY_PIN, on ? LOW : HIGH);
Serial.printf("[RELAY] %s\n", on ? "ON" : "OFF");
}
// βββββββββββββββββββββββββββββββββββββββββββββ
// LoRa TRANSMIT (UNCHANGED)
// βββββββββββββββββββββββββββββββββββββββββββββ
void transmitLoRa() {
char pkt[128];
snprintf(pkt, sizeof(pkt),
"TX,%s,%.1f,%.2f,%.1f,%.1f,%.3f,%.2f,%.2f,%d",
DEVICE_ID,
voltage, current, power, frequency,
totalKwh, monthlyKwh, prevDayKwh,
relayOn ? 1 : 0);
LoRa.beginPacket();
LoRa.print(pkt);
LoRa.endPacket();
Serial.print(F("[LoRa] TX: "));
Serial.println(pkt);
}
// βββββββββββββββββββββββββββββββββββββββββββββ
// LoRa RECEIVE (UNCHANGED logic; flash now deferred β fix #3)
// βββββββββββββββββββββββββββββββββββββββββββββ
void receiveLoRa() {
int sz = LoRa.parsePacket();
if (sz == 0 || sz > 64) return;
char buf[65] = {0};
uint8_t idx = 0;
while (LoRa.available() && idx < 64) buf[idx++] = (char)LoRa.read();
buf[idx] = '\0';
Serial.print(F("[LoRa] RX: ")); Serial.println(buf);
if (strncmp(buf, "RELAY,", 6) == 0) {
int s = atoi(&buf[6]);
if (s == 0 || s == 1) {
setRelay(s == 1);
triggerFlash(relayOn ? CLR_GOOD : CLR_ALERT); // deferred β safe
}
}
}
// βββββββββββββββββββββββββββββββββββββββββββββ
// BUTTON HANDLER (UNCHANGED)
// βββββββββββββββββββββββββββββββββββββββββββββ
void handleButton() {
bool raw = (digitalRead(BUTTON_PIN) == LOW);
uint32_t now = millis();
switch (btnState) {
case BTN_IDLE:
if (raw) { btnState = BTN_DEBOUNCE; btnTimer = now; }
break;
case BTN_DEBOUNCE:
if (!raw) {
btnState = BTN_IDLE;
} else if (now - btnTimer >= BTN_DEBOUNCE_MS) {
btnState = BTN_HELD;
btnPressTime = now;
btnLongDone = false;
}
break;
case BTN_HELD:
if (!raw) {
btnState = BTN_RELEASE; btnTimer = now;
} else if (!btnLongDone && (now - btnPressTime >= BTN_LONG_MIN_MS)) {
btnLongDone = true;
btnState = BTN_LONG_DONE;
resetTotalConsumption();
triggerFlash(CLR_WARN);
Serial.println(F("[BTN] Long press β total reset."));
}
break;
case BTN_LONG_DONE:
if (!raw) { btnState = BTN_RELEASE; btnTimer = now; }
break;
case BTN_RELEASE:
if (raw) {
btnState = BTN_HELD;
} else if (now - btnTimer >= BTN_DEBOUNCE_MS) {
uint32_t held = now - btnPressTime;
if (!btnLongDone && held < BTN_SHORT_MAX_MS) {
currentPage = (currentPage == 0) ? 1 : 0;
lastPage = 255;
triggerFlash(CLR_CYAN);
Serial.printf("[BTN] Page -> %d\n", currentPage);
}
btnLongDone = false;
btnState = BTN_IDLE;
}
break;
}
}
// βββββββββββββββββββββββββββββββββββββββββββββ
// NON-BLOCKING FLASH (FIX #3)
//
// triggerFlash() sets flashPending; it does NOT touch the TFT here.
// handleFlash() is the only place that issues SPI calls for the flash,
// and it runs from the main loop where the SPI bus is not in use by
// LoRa. This prevents LoRa-ISR / TFT-SPI contention.
// βββββββββββββββββββββββββββββββββββββββββββββ
void triggerFlash(uint16_t color) {
flashColor = color;
flashPending = true; // actual fillScreen deferred to handleFlash()
flashActive = false; // reset so handleFlash() knows to start fresh
}
void handleFlash() {
// Phase 1: pending β start the flash (do the fillScreen here, safely)
if (flashPending) {
flashPending = false;
flashActive = true;
flashStart = millis();
tft.fillScreen(flashColor); // one safe SPI call from main loop
return;
}
// Phase 2: active β wait FLASH_MS then restore
if (!flashActive) return;
if (millis() - flashStart >= FLASH_MS) {
flashActive = false;
tft.fillScreen(CLR_BG);
invalidateShadows(); // force full page redraw after flash
lastPage = 255;
}
}
// βββββββββββββββββββββββββββββββββββββββββββββ
// SHADOW INVALIDATION (FIX #2 helper)
// Called after flash or page change to force
// a complete repaint on the next draw cycle.
// βββββββββββββββββββββββββββββββββββββββββββββ
void invalidateShadows() {
p0.voltage = -1.0f;
p0.current = -1.0f;
p0.power = -1.0f;
p0.frequency = -1.0f;
p0.relayOn = !relayOn; // guaranteed mismatch
p0.pzemOk = !pzemOk;
p0.warmup = !(millis() < PZEM_WARMUP_MS);
p1.totalKwh = -1.0f;
p1.timeBuf[0] = '\0';
}
// βββββββββββββββββββββββββββββββββββββββββββββ
// REAL-TIME CLOCK (NEW β replaces getSimTime)
//
// Uses ESP32 SNTP via getLocalTime(). The internal RTC continues
// counting after WiFi disconnects, so no fallback branch is needed
// once ntpSynced is true.
// Format: DD/MM/YYYY HH:MM:SS
// If NTP has never synced, shows "Syncing NTP..."
// βββββββββββββββββββββββββββββββββββββββββββββ
void getRealTime(char* buf, size_t len) {
if (!ntpSynced) {
strncpy(buf, "Syncing NTP...", len);
buf[len - 1] = '\0';
return;
}
struct tm ti;
if (!getLocalTime(&ti)) {
strncpy(buf, "Time unavail.", len);
buf[len - 1] = '\0';
return;
}
snprintf(buf, len, "%02d/%02d/%04d %02d:%02d:%02d",
ti.tm_mday, ti.tm_mon + 1, ti.tm_year + 1900,
ti.tm_hour, ti.tm_min, ti.tm_sec);
}
// βββββββββββββββββββββββββββββββββββββββββββββ
// DISPLAY ROUTER (UNCHANGED logic, shadow check added)
// βββββββββββββββββββββββββββββββββββββββββββββ
void drawCurrentPage() {
if (flashActive || flashPending) return;
if (currentPage != lastPage) {
tft.fillScreen(CLR_BG);
invalidateShadows();
lastPage = currentPage;
}
if (currentPage == 0) drawPage0();
else drawPage1();
}
// βββββββββββββββββββββββββββββββββββββββββββββ
// DISPLAY HELPERS (UNCHANGED)
// βββββββββββββββββββββββββββββββββββββββββββββ
void drawHdr(const char* title, uint16_t color, const char* pg) {
tft.fillRect(0, 0, 160, 15, color);
tft.setTextSize(1);
tft.setTextColor(CLR_BG);
tft.setCursor(4, 4);
tft.print(title);
tft.setCursor(148, 4);
tft.print(pg);
}
void drawRow(uint8_t y, uint8_t h,
const char* label, const char* value,
uint16_t lblColor, uint16_t valColor) {
tft.fillRect(0, y, 160, h, CLR_BG);
tft.setTextSize(1);
tft.setTextColor(lblColor);
tft.setCursor(4, y + 4);
tft.print(label);
tft.setTextColor(valColor);
tft.setCursor(90, y + 4);
tft.print(value);
}
// βββββββββββββββββββββββββββββββββββββββββββββ
// PAGE 0: LIVE READINGS (FIX #2 β selective redraw)
//
// Each row is only repainted when its value has changed.
// Header is static once drawn (repainted only after page switch/flash).
// βββββββββββββββββββββββββββββββββββββββββββββ
void drawPage0() {
// Header β only on first draw of this page
if (lastPage == 255 || p0.voltage < 0.0f) {
drawHdr("LIVE READINGS", CLR_HDR0, "1");
}
char buf[20];
const uint8_t ROW_H = 18;
uint8_t y = 17;
// ββ Voltage ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
if (voltage != p0.voltage) {
snprintf(buf, sizeof(buf), "%.1f V", voltage);
drawRow(y, ROW_H, "Voltage", buf, CLR_LABEL,
(voltage >= PZEM_V_MIN && voltage <= PZEM_V_MAX) ? CLR_VALUE : CLR_WARN);
p0.voltage = voltage;
}
y += ROW_H;
// ββ Current ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
if (current != p0.current) {
snprintf(buf, sizeof(buf), "%.2f A", current);
drawRow(y, ROW_H, "Current", buf, CLR_LABEL, CLR_VALUE);
p0.current = current;
}
y += ROW_H;
// ββ Power ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
if (power != p0.power) {
snprintf(buf, sizeof(buf), "%.1f W", power);
drawRow(y, ROW_H, "Power", buf, CLR_LABEL, CLR_VALUE);
p0.power = power;
}
y += ROW_H;
// ββ Frequency ββββββββββββββββββββββββββββββββββββββββββββββββββββ
if (frequency != p0.frequency) {
snprintf(buf, sizeof(buf), "%.1f Hz", frequency);
drawRow(y, ROW_H, "Frequency", buf, CLR_LABEL,
(frequency >= PZEM_HZ_MIN && frequency <= PZEM_HZ_MAX)
? CLR_VALUE : CLR_WARN);
p0.frequency = frequency;
}
y += ROW_H;
// ββ Relay ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
if (relayOn != p0.relayOn) {
drawRow(y, ROW_H, "Relay",
relayOn ? "ON" : "OFF",
CLR_LABEL,
relayOn ? CLR_GOOD : CLR_ALERT);
p0.relayOn = relayOn;
}
y += ROW_H;
// ββ Status bar βββββββββββββββββββββββββββββββββββββββββββββββββββ
bool nowWarmup = (millis() < PZEM_WARMUP_MS);
if (pzemOk != p0.pzemOk || nowWarmup != p0.warmup) {
tft.fillRect(0, y, 160, 128 - y, CLR_DIM);
tft.setTextSize(1);
tft.setTextColor(pzemOk ? CLR_GOOD : CLR_ALERT);
tft.setCursor(4, y + 4);
tft.print(pzemOk ? "PZEM: OK" : "PZEM: FAIL");
if (nowWarmup) {
tft.setTextColor(CLR_WARN);
tft.setCursor(80, y + 4);
tft.print(F("WARMUP"));
}
p0.pzemOk = pzemOk;
p0.warmup = nowWarmup;
}
}
// βββββββββββββββββββββββββββββββββββββββββββββ
// PAGE 1: CONSUMPTION INFO (FIX #2 β selective redraw)
//
// Layout UNCHANGED from v2.1.
// Only totalKwh number and time string are updated when they change.
// Static chrome (labels, dividers, device ID, hint) drawn once.
// βββββββββββββββββββββββββββββββββββββββββββββ
void drawPage1() {
bool firstDraw = (p1.totalKwh < 0.0f);
// ββ Static chrome (drawn only on first paint of page) ββββββββββββ
if (firstDraw) {
drawHdr("CONSUMPTION", CLR_HDR1, "2");
tft.setTextSize(1);
tft.setTextColor(CLR_LABEL);
tft.setCursor(4, 22);
tft.print(F("TOTAL CONSUMPTION"));
tft.drawFastHLine(0, 32, 160, CLR_DIM);
tft.setTextSize(1);
tft.setTextColor(CLR_LABEL);
tft.setCursor(64, 64);
tft.print(F("kWh"));
tft.drawFastHLine(0, 74, 160, CLR_DIM);
tft.setTextSize(1);
tft.setTextColor(CLR_LABEL);
tft.setCursor(4, 78);
tft.print(F("Time"));
tft.drawFastHLine(0, 102, 160, CLR_DIM);
tft.setTextSize(1);
tft.setTextColor(CLR_LABEL);
tft.setCursor(4, 106);
tft.print(F("Device: "));
tft.setTextColor(CLR_CYAN);
tft.print(F(DEVICE_ID));
tft.setTextColor(CLR_DIM);
tft.setCursor(4, 118);
tft.print(F("[hold 3s] = reset total"));
}
// ββ Total kWh β repaint only when value changes ββββββββββββββββββ
if (totalKwh != p1.totalKwh) {
// Erase old large number area
tft.fillRect(0, 33, 160, 30, CLR_BG);
char kwh[20];
snprintf(kwh, sizeof(kwh), "%.3f", totalKwh);
tft.setTextSize(3);
tft.setTextColor(CLR_ORANGE);
uint8_t charW = 18;
uint8_t numLen = strlen(kwh);
uint8_t xStart = (160 - numLen * charW) / 2;
if (xStart > 160) xStart = 4;
tft.setCursor(xStart, 38);
tft.print(kwh);
// Restore "kWh" label (was cleared)
tft.setTextSize(1);
tft.setTextColor(CLR_LABEL);
tft.setCursor(64, 64);
tft.print(F("kWh"));
p1.totalKwh = totalKwh;
}
// ββ Time β repaint every second (string changes each second) βββββ
char timeBuf[20];
getRealTime(timeBuf, sizeof(timeBuf));
if (strncmp(timeBuf, p1.timeBuf, sizeof(timeBuf)) != 0) {
// Erase old time line
tft.fillRect(0, 83, 160, 18, CLR_BG);
tft.setTextSize(1);
tft.setTextColor(CLR_VALUE);
tft.setCursor(4, 90);
tft.print(timeBuf);
strncpy(p1.timeBuf, timeBuf, sizeof(p1.timeBuf));
p1.timeBuf[sizeof(p1.timeBuf) - 1] = '\0';
}
}Receiver CodeThe receiver code is the "brain" of the monitoring system.
- LoRa :
- It listens constantly for packets from the transmitter. It checks if the data is valid (not corrupted) before using it.
- OLED Status Screen:
- Shows you the IP address (so you know where to go in your browser), signal strength (RSSI), and connection status.
Web Dashboard:
- The ESP32 acts as a web server. You can log in to see:
- Live Readings: Current voltage and power.
- History: Daily and monthly usage charts.
- Billing: Calculates cost based on kWh usage.
- Control: A button to turn the Relay ON/OFF remotely.
- Remote Relay Control:
- When you click "Turn Off" on the website, the receiver sends a LoRa command back to the transmitter to switch the relay.
#include <Arduino.h>
#include <SPI.h>
#include <LoRa.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <WiFi.h>
#include <WebServer.h>
#include <Preferences.h>
#include <time.h>
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// CONFIGURATION
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
#define WIFI_SSID "ESP"
#define WIFI_PASS "abcd1234"
#define ADMIN_ID "admin"
#define ADMIN_PASS "admin123"
#define USER_ID "user1"
#define USER_PASS "pass123"
#define DEVICE_ID "1A"
#define NTP_SERVER "pool.ntp.org"
#define GMT_OFFSET_SEC 19800
#define DST_OFFSET_SEC 0
// Pin Map
#define LORA_SCK 12
#define LORA_MISO 11
#define LORA_MOSI 10
#define LORA_NSS 15
#define LORA_RST 14
#define LORA_DIO0 13
#define OLED_SDA 17
#define OLED_SCL 18
#define OLED_ADDR 0x3C
#define SCREEN_W 128
#define SCREEN_H 64
// LoRa
#define LORA_FREQ 433E6
#define LORA_BW 125E3
#define LORA_SF 7
#define LORA_TIMEOUT_MS 30000UL
// Timing
#define PREF_SAVE_INTERVAL_MS 300000UL
#define OLED_REFRESH_MS 10000UL
#define MONTH_CHECK_MS 60000UL
#define WIFI_WATCH_MS 15000UL
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// OBJECTS
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
SPIClass loRaSPI(HSPI);
Adafruit_SSD1306 oled(SCREEN_W, SCREEN_H, &Wire, -1);
WebServer server(80);
Preferences prefs;
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// LIVE DATA β all start at 0, never pre-loaded
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
struct EnergyData {
float voltage = 0.0f;
float current = 0.0f;
float power = 0.0f;
float freq = 0.0f;
float total = 0.0f;
float monthly = 0.0f;
float prevDay = 0.0f;
bool relay = true;
int rssi = 0;
uint32_t lastRx = 0;
} ed;
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// FLAGS & STATE
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
bool hasLiveData = false;
bool loraLost = false;
bool loRaActive = false;
float unitPrice = 8.0f;
bool billPaid = false;
uint8_t lastResetMonth = 255;
float dayHist[7] = {0, 0, 0, 0, 0, 0, 0};
float monHist[6] = {0, 0, 0, 0, 0, 0};
uint32_t lastPrefSave = 0;
uint32_t lastOledRefresh= 0;
uint32_t lastMonthCheck = 0;
uint32_t lastWiFiCheck = 0;
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// SESSION
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
char sessionToken[17] = {0};
uint32_t sessionExp = 0;
bool isAdminSession = false;
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// HTML BUFFER + HELPERS
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
static String g_html;
inline void H(const char* s) { g_html += s; }
inline void H(const __FlashStringHelper* s) { g_html += s; }
inline void H(int v) { g_html += v; }
inline void H(float v, unsigned int d = 2) { g_html += String(v, d); }
inline void H(bool v) { g_html += v ? "true" : "false"; }
inline void H(const String& s) { g_html += s; }
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// PROGMEM: SHARED CSS
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
const char SHARED_CSS[] PROGMEM = R"CSS(
<style>
:root{
--bg:#f0f4f8;--card:#fff;--sidebar:#1a2535;--sidebar-link:#8fa3b8;
--text:#1e293b;--sub:#64748b;--border:#e2e8f0;--accent:#3b82f6;
--green:#10b981;--red:#ef4444;--yellow:#f59e0b;
--shadow:0 1px 3px rgba(0,0,0,.06),0 4px 16px rgba(0,0,0,.06);
--radius:14px;--sidebar-w:230px;--topbar-h:58px;
}
body.dark{
--bg:#0d1117;--card:#161b22;--sidebar:#0a0f16;--sidebar-link:#8b949e;
--text:#e6edf3;--sub:#8b949e;--border:#30363d;
--shadow:0 1px 3px rgba(0,0,0,.3),0 4px 16px rgba(0,0,0,.3);
}
*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
body{font-family:'Segoe UI',system-ui,Arial,sans-serif;background:var(--bg);
color:var(--text);display:flex;min-height:100vh;transition:background .25s,color .25s}
#sidebar{width:var(--sidebar-w);background:var(--sidebar);display:flex;
flex-direction:column;min-height:100vh;position:fixed;left:0;top:0;
z-index:300;transition:width .25s;overflow:hidden}
#sidebar.collapsed{width:58px}
#sidebar.collapsed .nav-label,#sidebar.collapsed .logo-text,
#sidebar.collapsed .sidebar-ver,#sidebar.collapsed .nav-section{display:none}
.logo{padding:18px 16px;display:flex;align-items:center;gap:10px;
border-bottom:1px solid rgba(255,255,255,.06);white-space:nowrap;min-height:var(--topbar-h)}
.logo-icon{font-size:22px;flex-shrink:0}
.logo-text{font-size:15px;font-weight:700;color:#fff}
.sidebar-ver{font-size:10px;color:#4a5568;margin-top:2px}
.nav-section{padding:12px 10px 4px;font-size:10px;font-weight:600;
color:#4a5568;text-transform:uppercase;letter-spacing:.8px;white-space:nowrap}
.nav-link{display:flex;align-items:center;gap:10px;padding:11px 16px;
color:var(--sidebar-link);text-decoration:none;font-size:13.5px;
border-radius:8px;margin:1px 8px;transition:all .2s;white-space:nowrap}
.nav-link:hover{background:rgba(59,130,246,.15);color:#fff}
.nav-link.active{background:rgba(59,130,246,.25);color:#3b82f6;font-weight:600}
.nav-icon{font-size:16px;flex-shrink:0;width:20px;text-align:center}
.nav-divider{height:1px;background:rgba(255,255,255,.06);margin:8px 16px}
.nav-logout{display:flex;align-items:center;gap:10px;padding:11px 16px;
color:#f87171;cursor:pointer;font-size:13.5px;border-radius:8px;
margin:1px 8px;transition:all .2s;white-space:nowrap;margin-top:auto}
.nav-logout:hover{background:rgba(239,68,68,.15)}
#topbar{position:fixed;top:0;left:var(--sidebar-w);right:0;
height:var(--topbar-h);background:var(--card);
border-bottom:1px solid var(--border);display:flex;align-items:center;
padding:0 24px;gap:12px;z-index:200;transition:left .25s}
#topbar.wide{left:58px}
.topbar-title{flex:1;font-size:16px;font-weight:600;color:var(--text)}
.topbar-status{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--sub)}
.btn-icon{background:none;border:1.5px solid var(--border);border-radius:9px;
padding:7px 13px;cursor:pointer;font-size:13px;color:var(--text);
transition:all .2s;display:inline-flex;align-items:center;gap:5px}
.btn-icon:hover{background:var(--accent);color:#fff;border-color:var(--accent)}
#main{margin-left:var(--sidebar-w);margin-top:var(--topbar-h);
flex:1;padding:28px;transition:margin .25s;min-width:0}
#main.wide{margin-left:58px}
.page-header{margin-bottom:24px}
.page-header h1{font-size:21px;font-weight:700}
.page-header p{font-size:13px;color:var(--sub);margin-top:4px}
.skeleton{background:linear-gradient(90deg,var(--border) 25%,var(--bg) 50%,var(--border) 75%);
background-size:200% 100%;animation:shimmer 1.4s infinite;border-radius:6px}
@keyframes shimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}
.skel-val{height:28px;width:70%;margin:4px 0}
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(185px,1fr));
gap:16px;margin-bottom:24px}
.card{background:var(--card);padding:20px 18px;border-radius:var(--radius);
box-shadow:var(--shadow);border:1px solid var(--border);
transition:transform .2s,box-shadow .2s;position:relative;overflow:hidden}
.card:hover{transform:translateY(-2px);box-shadow:0 8px 24px rgba(0,0,0,.1)}
.card-lbl{font-size:11px;font-weight:600;color:var(--sub);
text-transform:uppercase;letter-spacing:.6px;margin-bottom:8px}
.card-val{font-size:22px;font-weight:700;color:var(--text);line-height:1.2}
.card-val.accent{color:var(--accent)}
.card-val.green{color:var(--green)}
.card-val.red{color:var(--red)}
.card-val.yellow{color:var(--yellow)}
.card-sub{font-size:11px;color:var(--sub);margin-top:5px}
.card-icon{position:absolute;top:16px;right:16px;font-size:22px;opacity:.12}
.sec-hdr{font-size:11px;font-weight:700;color:var(--sub);text-transform:uppercase;
letter-spacing:.7px;margin:24px 0 12px;padding-bottom:8px;
border-bottom:1px solid var(--border);display:flex;align-items:center;gap:8px}
.badge{display:inline-flex;align-items:center;gap:5px;padding:4px 10px;
border-radius:20px;font-size:11px;font-weight:600}
.badge-green{background:#d1fae5;color:#065f46}
.badge-red{background:#fee2e2;color:#991b1b}
.badge-yellow{background:#fef3c7;color:#92400e}
.badge-blue{background:#dbeafe;color:#1e40af}
.badge-gray{background:var(--border);color:var(--sub)}
body.dark .badge-green{background:#064e3b;color:#6ee7b7}
body.dark .badge-red{background:#450a0a;color:#fca5a5}
body.dark .badge-yellow{background:#451a03;color:#fcd34d}
body.dark .badge-blue{background:#1e3a5f;color:#93c5fd}
.alert{display:flex;align-items:center;gap:10px;padding:12px 16px;
border-radius:10px;font-size:13px;margin-bottom:18px;border:1px solid;
animation:fadeIn .3s ease}
.alert-red{background:#fff1f2;border-color:#fecdd3;color:#9f1239}
.alert-yellow{background:#fffbeb;border-color:#fde68a;color:#92400e}
.alert-blue{background:#eff6ff;border-color:#bfdbfe;color:#1e40af}
body.dark .alert-red{background:#450a0a44;border-color:#991b1b;color:#fca5a5}
body.dark .alert-yellow{background:#451a0344;border-color:#92400e;color:#fcd34d}
body.dark .alert-blue{background:#1e3a5f44;border-color:#1e40af;color:#93c5fd}
.tbl-wrap{background:var(--card);border-radius:var(--radius);overflow:hidden;
box-shadow:var(--shadow);border:1px solid var(--border);margin-bottom:20px}
table{width:100%;border-collapse:collapse}
thead th{background:var(--bg);padding:11px 16px;text-align:left;
font-size:11px;font-weight:700;color:var(--sub);text-transform:uppercase;
letter-spacing:.6px;border-bottom:1px solid var(--border)}
tbody td{padding:13px 16px;font-size:13.5px;border-bottom:1px solid var(--border)}
tbody tr:last-child td{border-bottom:none}
tbody tr:hover td{background:rgba(59,130,246,.03)}
td a{color:var(--accent);text-decoration:none;font-weight:600}
td a:hover{text-decoration:underline}
.chart-wrap{background:var(--card);padding:20px 20px 16px;
border-radius:var(--radius);box-shadow:var(--shadow);
border:1px solid var(--border);margin-bottom:20px}
.chart-title{font-size:12px;font-weight:600;color:var(--sub);
text-transform:uppercase;letter-spacing:.5px;margin-bottom:14px}
.form-group{margin-bottom:14px}
.form-group label{display:block;font-size:12px;font-weight:600;color:var(--sub);
margin-bottom:5px;text-transform:uppercase;letter-spacing:.4px}
input[type=text],input[type=password],input[type=number],select{
width:100%;padding:10px 13px;border:1.5px solid var(--border);
border-radius:9px;font-size:14px;background:var(--bg);
color:var(--text);transition:border .2s,box-shadow .2s}
input:focus,select:focus{outline:none;border-color:var(--accent);
box-shadow:0 0 0 3px rgba(59,130,246,.15)}
.btn{display:inline-flex;align-items:center;gap:6px;padding:10px 18px;
border:none;border-radius:9px;cursor:pointer;font-size:13px;
font-weight:600;transition:all .2s}
.btn:hover{transform:translateY(-1px)}
.btn-primary{background:var(--accent);color:#fff}
.btn-primary:hover{background:#2563eb}
.btn-success{background:var(--green);color:#fff}
.btn-success:hover{background:#059669}
.btn-danger{background:var(--red);color:#fff}
.btn-danger:hover{background:#dc2626}
.btn-block{display:flex;width:100%;justify-content:center;margin-top:12px}
.btn-sm{padding:7px 13px;font-size:12px}
.dot{width:8px;height:8px;border-radius:50%;display:inline-block;flex-shrink:0}
.dot-green{background:var(--green);box-shadow:0 0 6px var(--green)}
.dot-red{background:var(--red);box-shadow:0 0 6px var(--red)}
.dot-yellow{background:var(--yellow);box-shadow:0 0 6px var(--yellow)}
.dot-pulse{animation:pulse 2s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
.info-row{display:flex;justify-content:space-between;align-items:center;
padding:10px 0;border-bottom:1px solid var(--border);font-size:13.5px}
.info-row:last-child{border-bottom:none}
.info-row .lbl{color:var(--sub)}
.info-row .val{font-weight:600}
@keyframes fadeIn{from{opacity:0;transform:translateY(-6px)}to{opacity:1;transform:none}}
.fade-in{animation:fadeIn .3s ease}
@media(max-width:800px){
#sidebar{width:58px}
#sidebar .nav-label,#sidebar .logo-text,#sidebar .sidebar-ver,
#sidebar .nav-section{display:none}
#topbar{left:58px}
#main{margin-left:58px;padding:16px}
.cards{grid-template-columns:1fr 1fr}
}
@media(max-width:480px){
.cards{grid-template-columns:1fr}
#topbar{padding:0 14px;gap:8px}
}
</style>
)CSS";
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// PROGMEM: SHARED JS
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
const char SHARED_JS[] PROGMEM = R"JS(
<script>
(function(){
const t=localStorage.getItem('em-theme')||'light';
if(t==='dark')document.body.classList.add('dark');
})();
function toggleTheme(){
const d=document.body.classList.toggle('dark');
localStorage.setItem('em-theme',d?'dark':'light');
const b=document.getElementById('themeBtn');
if(b)b.textContent=d?'β Light':'π Dark';
}
function toggleSidebar(){
const s=document.getElementById('sidebar');
const t=document.getElementById('topbar');
const m=document.getElementById('main');
const c=s.classList.toggle('collapsed');
t.classList.toggle('wide',c);
m.classList.toggle('wide',c);
localStorage.setItem('em-sb',c?'1':'0');
}
(function(){
if(localStorage.getItem('em-sb')!=='1')return;
const s=document.getElementById('sidebar');
const t=document.getElementById('topbar');
const m=document.getElementById('main');
if(s){s.classList.add('collapsed');
if(t)t.classList.add('wide');
if(m)m.classList.add('wide');}
})();
window.addEventListener('DOMContentLoaded',function(){
const b=document.getElementById('themeBtn');
if(b)b.textContent=document.body.classList.contains('dark')?'β Light':'π Dark';
});
function setText(id,v){const e=document.getElementById(id);if(e)e.textContent=v;}
function setHTML(id,v){const e=document.getElementById(id);if(e)e.innerHTML=v;}
</script>
)JS";
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// PROGMEM: LOGIN PAGE
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
const char LOGIN_HTML[] PROGMEM = R"RAW(
<!DOCTYPE html><html lang="en"><head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Energy Meter Login</title>
<style>
:root{--bg:#0d1117;--card:#161b22;--accent:#3b82f6;--text:#e6edf3;
--sub:#8b949e;--border:#30363d;--red:#f87171}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Segoe UI',system-ui,Arial,sans-serif;
background:radial-gradient(ellipse at 60% 0%,#1e3a5f 0%,var(--bg) 65%);
min-height:100vh;display:flex;align-items:center;justify-content:center;color:var(--text)}
.box{background:var(--card);padding:40px 34px;border-radius:18px;width:350px;
box-shadow:0 8px 40px rgba(0,0,0,.6);border:1px solid var(--border);text-align:center}
.logo-wrap{width:64px;height:64px;background:linear-gradient(135deg,#1e3a8a,#3b82f6);
border-radius:16px;display:flex;align-items:center;justify-content:center;
font-size:30px;margin:0 auto 16px;box-shadow:0 0 24px rgba(59,130,246,.4)}
h2{font-size:20px;margin-bottom:4px;font-weight:700}
.sub{color:var(--sub);font-size:13px;margin-bottom:30px}
.fg{text-align:left;margin-bottom:12px}
.fg label{display:block;font-size:11px;font-weight:600;color:var(--sub);
margin-bottom:5px;text-transform:uppercase;letter-spacing:.5px}
input{width:100%;padding:11px 14px;background:#0d1117;border:1.5px solid var(--border);
color:var(--text);border-radius:9px;font-size:14px;transition:.2s}
input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(59,130,246,.2)}
.btn-l{width:100%;padding:13px;margin-top:6px;background:var(--accent);
color:#fff;border:none;border-radius:9px;font-size:14px;font-weight:700;
cursor:pointer;transition:.2s}
.btn-l:hover{background:#2563eb;transform:translateY(-1px)}
.err{color:var(--red);font-size:13px;margin-top:14px;padding:10px 14px;
background:rgba(239,68,68,.12);border-radius:7px;border:1px solid rgba(239,68,68,.3)}
.brand{font-size:11px;color:var(--sub);margin-top:22px}
</style></head><body>
<div class="box">
<div class="logo-wrap">⚡</div>
<h2>Energy Meter</h2>
<div class="sub">Postpaid Management System</div>
<form method="POST" action="/auth" autocomplete="on">
<div class="fg"><label>User ID</label>
<input name="u" placeholder="Enter your ID" autocomplete="username" required></div>
<div class="fg"><label>Password</label>
<input type="password" name="p" placeholder="Enter password"
autocomplete="current-password" required></div>
<button class="btn-l" type="submit">Sign In</button>
</form>
__ERR__
<div class="brand">ESP32-S3 · LoRa 433 MHz · v3.0</div>
</div>
</body></html>
)RAW";
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// FORWARD DECLARATIONS
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
void updateOLED();
bool checkAuth();
void redirectLogin();
void sendLoRa(const char* type, float val);
void checkMonthlyReset();
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// PAGE HEAD β sidebar + topbar
// βββββββββββββββββββββββββββββββββββββββββοΏ½οΏ½οΏ½ββββββββββββββββββββββββββ
void pageHead(const char* title, bool adminView, const char* activeLink) {
g_html.reserve(30000);
g_html = "";
H("<!DOCTYPE html><html lang='en'><head>"
"<meta charset='UTF-8'>"
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
"<title>");
H(title);
H(" - Energy Meter</title>");
H(FPSTR(SHARED_CSS));
H("<script src='https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js'"
" defer></script>");
H("</head><body>");
// Sidebar
H("<nav id='sidebar'>");
H("<div class='logo'>"
"<span class='logo-icon'>⚡</span>"
"<div><div class='logo-text'>Energy Meter</div>"
"<div class='sidebar-ver'>v3.0 · " DEVICE_ID "</div></div>"
"</div>");
if (adminView) {
H("<div class='nav-section'>Admin</div>");
H("<a class='nav-link");
if (strcmp(activeLink, "/admin") == 0) H(" active");
H("' href='/admin'><span class='nav-icon'>📊</span>"
"<span class='nav-label'> Dashboard</span></a>");
H("<a class='nav-link");
if (strcmp(activeLink, "/admin/unitprice") == 0) H(" active");
H("' href='/admin/unitprice'><span class='nav-icon'>💲</span>"
"<span class='nav-label'> Unit Price</span></a>");
H("<div class='nav-divider'></div>");
H("<div class='nav-section'>System</div>");
H("<a class='nav-link");
if (strcmp(activeLink, "/settings") == 0) H(" active");
H("' href='/settings'><span class='nav-icon'>⚙</span>"
"<span class='nav-label'> Settings</span></a>");
} else {
H("<div class='nav-section'>My Meter</div>");
H("<a class='nav-link");
if (strcmp(activeLink, "/") == 0) H(" active");
H("' href='/'><span class='nav-icon'>🏠</span>"
"<span class='nav-label'> Home</span></a>");
H("<a class='nav-link");
if (strcmp(activeLink, "/usage") == 0) H(" active");
H("' href='/usage'><span class='nav-icon'>📈</span>"
"<span class='nav-label'> Live Usage</span></a>");
H("<a class='nav-link");
if (strcmp(activeLink, "/bill") == 0) H(" active");
H("' href='/bill'><span class='nav-icon'>🧾</span>"
"<span class='nav-label'> My Bill</span></a>");
H("<div class='nav-divider'></div>");
H("<div class='nav-section'>Account</div>");
H("<a class='nav-link");
if (strcmp(activeLink, "/settings") == 0) H(" active");
H("' href='/settings'><span class='nav-icon'>⚙</span>"
"<span class='nav-label'> Settings</span></a>");
}
H("<div class='nav-divider'></div>");
H("<div class='nav-logout' onclick=\"location='/out'\">"
"<span class='nav-icon'>⏻</span>"
"<span class='nav-label'> Logout</span></div>");
H("</nav>");
// Topbar
H("<header id='topbar'>"
"<button class='btn-icon' onclick='toggleSidebar()'>☰</button>"
"<span class='topbar-title'>");
H(title);
H("</span>");
H("<span class='topbar-status' id='topbarStatus'>");
if (!hasLiveData) {
H("<span class='dot dot-yellow dot-pulse'></span> Waiting");
} else if (loraLost) {
H("<span class='dot dot-red'></span> Signal lost");
} else {
H("<span class='dot dot-green dot-pulse'></span> Live");
}
H("</span>");
H("<button id='themeBtn' class='btn-icon' onclick='toggleTheme()'>🌙 Dark</button>"
"<button class='btn-icon' onclick=\"location='/out'\">⏻</button>"
"</header>");
H("<main id='main'>");
}
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// PAGE FOOT
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
void pageFoot() {
H("</main>");
H(FPSTR(SHARED_JS));
H("</body></html>");
}
// ββββββββββββββοΏ½οΏ½βββββββββββββββββββββββββββββββββββββββββββββββββββββ
// BADGE HELPERS
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
String valOrDash(float v, unsigned int dec = 2) {
if (!hasLiveData) return "--";
return String(v, dec);
}
const char* relayBadge() {
if (ed.relay)
return "<span class='badge badge-green'>"
"<span class='dot dot-green'></span> ON</span>";
return "<span class='badge badge-red'>"
"<span class='dot dot-red'></span> OFF</span>";
}
const char* billBadge() {
if (!hasLiveData)
return "<span class='badge badge-gray'>Pending</span>";
if (billPaid)
return "<span class='badge badge-green'>✓ Paid</span>";
return "<span class='badge badge-red'>✗ Unpaid</span>";
}
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// SHARED: ALERT BANNERS
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
void emitAlerts() {
if (!hasLiveData) {
H("<div class='alert alert-blue fade-in'>"
"<span style='font-size:20px'>📡</span>"
"<div><strong>Waiting for meter data</strong>"
"<div style='font-size:12px;margin-top:2px'>"
"No LoRa packet received yet. Values appear after first transmission."
"</div></div></div>");
return;
}
if (loraLost) {
H("<div class='alert alert-red fade-in'>"
"<span style='font-size:20px'>⚠</span>"
"<div><strong>LoRa signal lost</strong> — last packet ");
H((int)((millis() - ed.lastRx) / 1000));
H(" s ago. Showing frozen values.</div></div>");
}
if (!billPaid) {
float bill = ed.monthly * unitPrice;
if (bill > 0.0f) {
H("<div class='alert alert-yellow fade-in'>"
"<span style='font-size:18px'>🧾</span>"
"<div><strong>Bill unpaid</strong> — ₹");
H(bill, 2);
H(" due. Contact your energy provider.</div></div>");
}
}
}
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// SHARED: POLL SCRIPT (self-scheduling fetch, no page reload)
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
void emitPollScript(uint16_t ms, bool withRelay = false) {
H("<script>(async function poll(){");
H("try{");
H("const r=await fetch('/api/data',{credentials:'same-origin'});");
H("if(r.status===401){location='/login';return;}");
H("const d=await r.json();");
// Topbar status
H("const ts=document.getElementById('topbarStatus');");
H("if(ts){");
H("if(!d.hasLiveData)"
"ts.innerHTML=\"<span class='dot dot-yellow dot-pulse'></span> Waiting\";");
H("else if(d.loraLost)"
"ts.innerHTML=\"<span class='dot dot-red'></span> Signal lost\";");
H("else "
"ts.innerHTML=\"<span class='dot dot-green dot-pulse'></span> Live\";");
H("}");
H("if(d.hasLiveData){");
H("setText('lv-v', d.voltage.toFixed(1)+' V');");
H("setText('lv-a', d.current.toFixed(2)+' A');");
H("setText('lv-w', d.power.toFixed(1)+' W');");
H("setText('lv-f', d.freq.toFixed(1)+' Hz');");
H("setText('lv-pd', d.prevDay.toFixed(2)+' kWh');");
H("setText('lv-mo', d.monthly.toFixed(2)+' kWh');");
H("setText('lv-tot', d.total.toFixed(3)+' kWh');");
H("setText('lv-bill','\\u20B9'+d.bill.toFixed(2));");
H("setText('lv-rssi',d.rssi+' dBm');");
H("setHTML('lv-billstat',d.paid"
"?\"<span class='badge badge-green'>✓ Paid</span>\""
":\"<span class='badge badge-red'>✗ Unpaid</span>\");");
if (withRelay) {
H("setHTML('lv-relay',d.relay"
"?\"<span class='badge badge-green'>"
"<span class='dot dot-green'></span> ON</span>\""
":\"<span class='badge badge-red'>"
"<span class='dot dot-red'></span> OFF</span>\");");
H("const rb=document.getElementById('relBtn');");
H("if(rb){rb.innerHTML=d.relay"
"?\"<button class='btn btn-danger btn-sm' onclick='setRelay(0)'>"
"Cut Power</button>\""
":\"<button class='btn btn-success btn-sm' onclick='setRelay(1)'>"
"Restore Power</button>\";}");
}
H("}"); // end if hasLiveData
H("}catch(e){}");
H("setTimeout(poll,"); H((int)ms); H(");");
H("})();</script>");
}
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// CHART EMITTERS
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
void emitWeeklyChart(const char* cid) {
H("<script>window.addEventListener('load',function(){");
H("const ctx=document.getElementById('"); H(cid); H("');if(!ctx)return;");
H("new Chart(ctx.getContext('2d'),{type:'bar',data:{");
H("labels:['Mon','Tue','Wed','Thu','Fri','Sat','Sun'],");
H("datasets:[{label:'kWh',data:[");
for (int i = 0; i < 7; i++) { H(dayHist[i], 2); if (i < 6) H(","); }
H("],backgroundColor:'rgba(59,130,246,0.75)',borderRadius:6}]},");
H("options:{responsive:true,plugins:{legend:{display:false}},");
H("scales:{x:{grid:{display:false}},y:{beginAtZero:true}}}});});</script>");
}
void emitMonthlyChart(const char* cid) {
H("<script>window.addEventListener('load',function(){");
H("const ctx=document.getElementById('"); H(cid); H("');if(!ctx)return;");
H("new Chart(ctx.getContext('2d'),{type:'line',data:{");
H("labels:['6m ago','5m ago','4m ago','3m ago','2m ago','This month'],");
H("datasets:[{label:'kWh',data:[");
for (int i = 0; i < 6; i++) { H(monHist[i], 2); if (i < 5) H(","); }
H("],borderColor:'#3b82f6',backgroundColor:'rgba(59,130,246,0.08)',");
H("tension:0.4,fill:true,pointBackgroundColor:'#3b82f6',pointRadius:5}]},");
H("options:{responsive:true,plugins:{legend:{display:false}},");
H("scales:{x:{grid:{display:false}},y:{beginAtZero:true}}}});});</script>");
}
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// PAGE: ADMIN TABLE /admin
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
void buildAdminTable() {
pageHead("Admin Dashboard", true, "/admin");
H("<div class='page-header'><h1>Admin Dashboard</h1>"
"<p>Monitor and manage connected meter devices.</p></div>");
emitAlerts();
float bill = hasLiveData ? (ed.monthly * unitPrice) : 0.0f;
H("<div class='cards'>");
H("<div class='card'><div class='card-icon'>🏭</div>"
"<div class='card-lbl'>Total Devices</div>"
"<div class='card-val accent'>1</div>"
"<div class='card-sub'>Active on network</div></div>");
H("<div class='card'><div class='card-icon'>⚡</div>"
"<div class='card-lbl'>Monthly Consumption</div>"
"<div class='card-val'>");
H(hasLiveData ? String(ed.monthly, 2) + " kWh" : "--");
H("</div><div class='card-sub'>Current billing period</div></div>");
H("<div class='card'><div class='card-icon'>💰</div>"
"<div class='card-lbl'>Monthly Bill</div>"
"<div class='card-val'>");
H(hasLiveData ? "₹" + String(bill, 2) : "--");
H("</div><div class='card-sub'>@ ₹");
H(unitPrice, 2);
H("/kWh</div></div>");
H("<div class='card'><div class='card-icon'>🧾</div>"
"<div class='card-lbl'>Bill Status</div>"
"<div class='card-val' style='font-size:16px;margin-top:4px'>");
H(billBadge());
H("</div></div>");
H("</div>");
H("<div class='sec-hdr'>Registered Devices</div>");
H("<div class='tbl-wrap'><table>"
"<thead><tr><th>Unique ID</th><th>Total kWh</th>"
"<th>Monthly Bill</th><th>Status</th><th>Signal</th></tr></thead><tbody>");
H("<tr>");
H("<td><a href='/admin?id=" DEVICE_ID "'>" DEVICE_ID "</a></td>");
H("<td>"); H(hasLiveData ? String(ed.total, 3) + " kWh" : "--"); H("</td>");
H("<td>"); H(hasLiveData ? "₹" + String(bill, 2) : "--"); H("</td>");
H("<td>"); H(billBadge()); H("</td>");
H("<td>");
if (!hasLiveData) {
H("<span class='badge badge-yellow'>"
"<span class='dot dot-yellow'></span> Waiting</span>");
} else if (loraLost) {
H("<span class='badge badge-red'>"
"<span class='dot dot-red'></span> Lost</span>");
} else {
H("<span class='badge badge-green'>"
"<span class='dot dot-green dot-pulse'></span> Online</span>");
}
H("</td></tr></tbody></table></div>");
H("<div class='chart-wrap'>"
"<div class='chart-title'>Monthly Consumption Trend (kWh)</div>"
"<canvas id='mc' height='75'></canvas></div>");
emitMonthlyChart("mc");
emitPollScript(5000);
pageFoot();
}
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// PAGE: ADMIN DETAIL /admin?id=1A
// βββββββββοΏ½οΏ½οΏ½ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
void buildAdminDetail() {
pageHead("Device " DEVICE_ID, true, "/admin");
H("<div class='page-header'>"
"<p style='font-size:12px;color:var(--sub);margin-bottom:6px'>"
"<a href='/admin' style='color:var(--accent)'>Admin</a> › "
"Device " DEVICE_ID "</p>"
"<h1>Device " DEVICE_ID " — Detail</h1>"
"<p>Real-time readings, billing and relay control.</p></div>");
emitAlerts();
// Live readings
H("<div class='sec-hdr'>Live Readings</div><div class='cards'>");
struct { const char* icon; const char* lbl; const char* id; String val; const char* sub; } cards[] = {
{"🔌","Voltage", "lv-v", valOrDash(ed.voltage, 1) + " V", "Nominal 230 V"},
{"〜", "Current", "lv-a", valOrDash(ed.current, 2) + " A", "Load current" },
{"💡","Power", "lv-w", valOrDash(ed.power, 1) + " W", "Active power" },
{"🔄","Frequency", "lv-f", valOrDash(ed.freq, 1) + " Hz", "Grid frequency"},
};
for (auto& c : cards) {
H("<div class='card'><div class='card-icon'>"); H(c.icon); H("</div>");
H("<div class='card-lbl'>"); H(c.lbl); H("</div>");
if (!hasLiveData) {
H("<div class='skeleton skel-val'></div>");
} else {
H("<div class='card-val' id='"); H(c.id); H("'>"); H(c.val); H("</div>");
}
H("<div class='card-sub'>"); H(c.sub); H("</div></div>");
}
H("</div>");
// Consumption
H("<div class='sec-hdr'>Consumption</div><div class='cards'>");
struct { const char* icon; const char* lbl; const char* id; String val; const char* sub; } cc[] = {
{"📅","Previous Day", "lv-pd", valOrDash(ed.prevDay, 2) + " kWh", "Yesterday"},
{"📆","This Month", "lv-mo", valOrDash(ed.monthly, 2) + " kWh", "Billing period"},
{"📁","Lifetime Total", "lv-tot", valOrDash(ed.total, 3) + " kWh", "All time"},
};
for (auto& c : cc) {
H("<div class='card'><div class='card-icon'>"); H(c.icon); H("</div>");
H("<div class='card-lbl'>"); H(c.lbl); H("</div>");
if (!hasLiveData) {
H("<div class='skeleton skel-val'></div>");
} else {
H("<div class='card-val' id='"); H(c.id); H("'>"); H(c.val); H("</div>");
}
H("<div class='card-sub'>"); H(c.sub); H("</div></div>");
}
H("</div>");
// Billing
H("<div class='sec-hdr'>Billing</div><div class='cards'>");
H("<div class='card'><div class='card-icon'>💲</div>"
"<div class='card-lbl'>Unit Price</div>"
"<div class='card-val accent'>₹");
H(unitPrice, 2);
H("/kWh</div><div class='card-sub'>Admin configurable</div></div>");
H("<div class='card'><div class='card-icon'>🧾</div>"
"<div class='card-lbl'>Monthly Bill</div>"
"<div class='card-val' id='lv-bill'>");
H(hasLiveData ? "₹" + String(ed.monthly * unitPrice, 2) : "--");
H("</div><div class='card-sub'>Monthly kWh x Unit Price</div></div>");
H("<div class='card'><div class='card-icon'>✅</div>"
"<div class='card-lbl'>Bill Status</div>"
"<div id='lv-billstat' style='margin-top:6px'>");
H(billBadge());
H("</div>");
if (hasLiveData) {
H("<div style='margin-top:10px'>"
"<button class='btn btn-sm ");
H(billPaid ? "btn-danger" : "btn-success");
H("' onclick='markBill(");
H(billPaid ? 0 : 1);
H(")'>");
H(billPaid ? "Mark Unpaid" : "Mark Paid");
H("</button></div>");
}
H("</div>");
H("</div>"); // end billing cards
// Relay control
H("<div class='sec-hdr'>Relay Control</div><div class='cards'>");
H("<div class='card'><div class='card-icon'>🔌</div>"
"<div class='card-lbl'>Relay Status</div>"
"<div id='lv-relay' style='margin-top:6px'>");
H(relayBadge());
H("</div><div class='card-sub'>Manual admin control only</div></div>");
H("<div class='card'><div class='card-icon'>🎛</div>"
"<div class='card-lbl'>Control</div>"
"<div id='relBtn' style='margin-top:10px'>");
if (ed.relay) {
H("<button class='btn btn-danger btn-sm' onclick='setRelay(0)'>Cut Power</button>");
} else {
H("<button class='btn btn-success btn-sm' onclick='setRelay(1)'>Restore Power</button>");
}
H("</div><div class='card-sub'>Sent via LoRa</div></div>");
H("<div class='card'><div class='card-icon'>📶</div>"
"<div class='card-lbl'>LoRa RSSI</div>"
"<div class='card-val' id='lv-rssi'>");
H(hasLiveData ? String(ed.rssi) + " dBm" : "--");
H("</div><div class='card-sub'>Signal strength</div></div>");
H("</div>"); // end relay cards
// Charts
H("<div class='chart-wrap'>"
"<div class='chart-title'>Weekly Usage (kWh)</div>"
"<canvas id='wc' height='80'></canvas></div>");
H("<div class='chart-wrap'>"
"<div class='chart-title'>Monthly Trend (kWh)</div>"
"<canvas id='mc' height='80'></canvas></div>");
emitWeeklyChart("wc");
emitMonthlyChart("mc");
H("<script>"
"async function setRelay(s){"
"const r=await fetch('/api/relay?s='+s,{method:'POST',credentials:'same-origin'});"
"if(r.ok)setTimeout(()=>location.reload(),700);}"
"async function markBill(s){"
"const r=await fetch('/api/billstatus?s='+s,{method:'POST',credentials:'same-origin'});"
"if(r.ok)setTimeout(()=>location.reload(),500);}"
"</script>");
emitPollScript(3000, true);
pageFoot();
}
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// PAGE: UNIT PRICE /admin/unitprice
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
void buildUnitPrice() {
pageHead("Unit Price", true, "/admin/unitprice");
H("<div class='page-header'><h1>Unit Price Configuration</h1>"
"<p>Set the tariff rate for monthly billing.</p></div>");
H("<div class='cards'>");
H("<div class='card'><div class='card-icon'>💲</div>"
"<div class='card-lbl'>Current Price</div>"
"<div class='card-val green'>₹");
H(unitPrice, 2);
H("/kWh</div></div>");
H("<div class='card'><div class='card-icon'>🧾</div>"
"<div class='card-lbl'>Current Monthly Bill</div>"
"<div class='card-val'>");
H(hasLiveData ? "₹" + String(ed.monthly * unitPrice, 2) : "--");
H("</div><div class='card-sub'>");
H(hasLiveData
? String(ed.monthly, 2) + " kWh x ₹" + String(unitPrice, 2)
: "No live data yet");
H("</div></div>");
H("</div>");
H("<div style='max-width:420px'><div class='card'>");
H("<div class='sec-hdr' style='margin-top:0'>Set New Price</div>");
H("<div class='form-group'><label>New Price (₹ per kWh)</label>"
"<input type='number' id='price' step='0.5' min='1' max='100'"
" placeholder='e.g. 8.00' value='");
H(unitPrice, 2);
H("'></div>");
H("<button class='btn btn-primary btn-block' onclick='savePrice()'>Save Price</button>");
H("<p id='pmsg' style='margin-top:12px;font-size:13px;"
"color:var(--green);min-height:18px'></p>");
H("</div></div>");
if (hasLiveData) {
H("<div class='sec-hdr'>Pricing Impact</div>");
H("<div class='tbl-wrap'><table>"
"<thead><tr><th>Description</th><th>Value</th></tr></thead><tbody>");
H("<tr><td>Monthly Usage</td><td>");
H(ed.monthly, 2); H(" kWh</td></tr>");
H("<tr><td>Monthly Bill (current price)</td><td>₹");
H(ed.monthly * unitPrice, 2); H("</td></tr>");
H("<tr><td>Lifetime Consumption</td><td>");
H(ed.total, 3); H(" kWh</td></tr>");
H("<tr><td>Lifetime Cost (current price)</td><td>₹");
H(ed.total * unitPrice, 2); H("</td></tr>");
H("</tbody></table></div>");
}
H("<script>"
"async function savePrice(){"
"const p=parseFloat(document.getElementById('price').value);"
"if(isNaN(p)||p<=0||p>100){alert('Enter a valid price (1-100)');return;}"
"const r=await fetch('/api/unitprice',{"
"method:'POST',credentials:'same-origin',"
"body:'price='+p,"
"headers:{'Content-Type':'application/x-www-form-urlencoded'}});"
"const m=document.getElementById('pmsg');"
"if(r.ok){m.style.color='var(--green)';"
"m.textContent='Saved: \u20B9'+p.toFixed(2)+'/kWh';"
"setTimeout(()=>location.reload(),1500);}"
"else{m.style.color='var(--red)';m.textContent='Failed to save.';}"
"}"
"</script>");
emitPollScript(10000);
pageFoot();
}
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// PAGE: USER HOME /
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
void buildUserHome() {
pageHead("Home", false, "/");
H("<div class='page-header'><h1>Welcome</h1>"
"<p>Your postpaid energy meter overview.</p></div>");
emitAlerts();
float bill = hasLiveData ? (ed.monthly * unitPrice) : 0.0f;
float dailyAvg = (hasLiveData && ed.monthly > 0.0f) ? (ed.monthly / 30.0f) : 0.0f;
H("<div class='cards'>");
H("<div class='card'><div class='card-icon'>📆</div>"
"<div class='card-lbl'>Monthly Usage</div>"
"<div class='card-val' id='lv-mo'>");
H(hasLiveData ? String(ed.monthly, 2) + " kWh" : "--");
H("</div><div class='card-sub'>Billing period</div></div>");
H("<div class='card'><div class='card-icon'>💰</div>"
"<div class='card-lbl'>Monthly Bill</div>"
"<div class='card-val' id='lv-bill'>");
H(hasLiveData ? "₹" + String(bill, 2) : "--");
H("</div><div class='card-sub'>@ ₹"); H(unitPrice, 2); H("/kWh</div></div>");
H("<div class='card'><div class='card-icon'>📊</div>"
"<div class='card-lbl'>Daily Average</div>"
"<div class='card-val'>");
H(hasLiveData ? String(dailyAvg, 2) + " kWh" : "--");
H("</div><div class='card-sub'>Estimated from month</div></div>");
H("<div class='card'><div class='card-icon'>🧾</div>"
"<div class='card-lbl'>Bill Status</div>"
"<div id='lv-billstat' style='margin-top:6px'>");
H(billBadge());
H("</div></div>");
H("</div>");
H("<div class='chart-wrap'>"
"<div class='chart-title'>Monthly Consumption Trend (kWh)</div>"
"<canvas id='mc' height='75'></canvas></div>");
H("<div class='chart-wrap'>"
"<div class='chart-title'>Last 7 Days (kWh)</div>"
"<canvas id='wc' height='75'></canvas></div>");
emitMonthlyChart("mc");
emitWeeklyChart("wc");
emitPollScript(5000);
pageFoot();
}
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// PAGE: USER USAGE /usage
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
void buildUserUsage() {
pageHead("Live Usage", false,"/usage");
H("<div class='page-header'><h1>Live Usage</h1>"
"<p>Real-time electrical readings from your meter.</p></div>");
emitAlerts();
H("<div class='sec-hdr'>Real-Time Readings</div><div class='cards'>");
struct { const char* icon; const char* lbl; const char* id;
String val; const char* sub; } lr[] = {
{"🔌","Voltage", "lv-v", valOrDash(ed.voltage, 1)+" V", "Nominal 230 V"},
{"〜", "Current", "lv-a", valOrDash(ed.current, 2)+" A", "Load current"},
{"💡","Power", "lv-w", valOrDash(ed.power, 1)+" W", "Active power"},
{"🔄","Frequency", "lv-f", valOrDash(ed.freq, 1)+" Hz", "Grid frequency"},
};
for (auto& c : lr) {
H("<div class='card'><div class='card-icon'>"); H(c.icon); H("</div>");
H("<div class='card-lbl'>"); H(c.lbl); H("</div>");
if (!hasLiveData) {
H("<div class='skeleton skel-val'></div>");
} else {
H("<div class='card-val' id='"); H(c.id); H("'>"); H(c.val); H("</div>");
}
H("<div class='card-sub'>"); H(c.sub); H("</div></div>");
}
H("</div>");
H("<div class='sec-hdr'>Consumption Summary</div><div class='cards'>");
struct { const char* icon; const char* lbl; const char* id;
String val; const char* sub; } cs[] = {
{"📅","Previous Day", "lv-pd", valOrDash(ed.prevDay, 2)+" kWh","Yesterday"},
{"📆","This Month", "lv-mo", valOrDash(ed.monthly, 2)+" kWh","Billing period"},
{"📁","Lifetime Total", "lv-tot", valOrDash(ed.total, 3)+" kWh","All time"},
};
for (auto& c : cs) {
H("<div class='card'><div class='card-icon'>"); H(c.icon); H("</div>");
H("<div class='card-lbl'>"); H(c.lbl); H("</div>");
if (!hasLiveData) {
H("<div class='skeleton skel-val'></div>");
} else {
H("<div class='card-val' id='"); H(c.id); H("'>"); H(c.val); H("</div>");
}
H("<div class='card-sub'>"); H(c.sub); H("</div></div>");
}
H("</div>");
H("<div class='chart-wrap'>"
"<div class='chart-title'>Last 7 Days (kWh)</div>"
"<canvas id='wc' height='80'></canvas></div>");
emitWeeklyChart("wc");
emitPollScript(3000);
pageFoot();
}
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// PAGE: USER BILL /bill
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
void buildUserBill() {
pageHead("My Bill", false, "/bill");
H("<div class='page-header'><h1>My Bill</h1>"
"<p>Current billing period summary and payment status.</p></div>");
emitAlerts();
float bill = hasLiveData ? (ed.monthly * unitPrice) : 0.0f;
H("<div class='cards'>");
H("<div class='card'><div class='card-icon'>📆</div>"
"<div class='card-lbl'>Monthly Usage</div>"
"<div class='card-val' id='lv-mo'>");
H(hasLiveData ? String(ed.monthly, 2) + " kWh" : "--");
H("</div></div>");
H("<div class='card'><div class='card-icon'>💲</div>"
"<div class='card-lbl'>Unit Price</div>"
"<div class='card-val accent'>₹");
H(unitPrice, 2);
H("/kWh</div></div>");
H("<div class='card'><div class='card-icon'>💰</div>"
"<div class='card-lbl'>Total Bill</div>"
"<div class='card-val' id='lv-bill'>");
H(hasLiveData ? "₹" + String(bill, 2) : "--");
H("</div></div>");
H("<div class='card'><div class='card-icon'>🧾</div>"
"<div class='card-lbl'>Status</div>"
"<div id='lv-billstat' style='margin-top:6px'>");
H(billBadge());
H("</div></div>");
H("</div>"); // end cards
// Bill breakdown table
H("<div class='sec-hdr'>Bill Breakdown</div>");
H("<div class='tbl-wrap'><table>"
"<thead><tr><th>Description</th><th>Detail</th><th>Amount</th></tr></thead><tbody>");
H("<tr><td>Energy Consumed</td><td>");
H(hasLiveData ? String(ed.monthly, 2) + " kWh x ₹" + String(unitPrice, 2) : "--");
H("</td><td>");
H(hasLiveData ? "₹" + String(bill, 2) : "--");
H("</td></tr>");
H("<tr><td>Previous Day Usage</td><td>");
H(hasLiveData ? String(ed.prevDay, 2) + " kWh" : "--");
H("</td><td>—</td></tr>");
H("<tr><td>Lifetime Consumption</td><td>");
H(hasLiveData ? String(ed.total, 3) + " kWh" : "--");
H("</td><td>—</td></tr>");
H("</tbody></table></div>");
if (hasLiveData && !billPaid) {
H("<div class='alert alert-yellow'>"
"<span style='font-size:18px'>ℹ</span>"
"<div>Your bill is <strong>unpaid</strong>. "
"Please contact your energy provider to settle the amount.</div></div>");
}
emitPollScript(8000);
pageFoot();
}
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// PAGE: SETTINGS /settings
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
void buildSettings() {
pageHead("Settings", isAdminSession, "/settings");
H("<div class='page-header'><h1>Settings</h1>"
"<p>System information and account details.</p></div>");
H("<div class='sec-hdr'>Profile</div>");
H("<div style='max-width:480px'><div class='card'>");
// Info row helper via lambda
auto IR = [&](const char* lbl, const char* val) {
g_html += "<div class='info-row'>"
"<span class='lbl'>"; g_html += lbl; g_html += "</span>"
"<span class='val'>"; g_html += val; g_html += "</span>"
"</div>";
};
IR("Role", isAdminSession ? "Administrator" : "User");
IR("User ID", isAdminSession ? ADMIN_ID : USER_ID);
IR("Device ID", DEVICE_ID);
IR("Firmware", "v3.0 Postpaid");
{
String ssid = WiFi.SSID();
IR("WiFi SSID", ssid.c_str());
String ip = (WiFi.status() == WL_CONNECTED)
? WiFi.localIP().toString()
: "Not connected";
IR("IP Address", ip.c_str());
}
{
char rb[20];
if (hasLiveData) {
snprintf(rb, sizeof(rb), "%d dBm", ed.rssi);
} else {
snprintf(rb, sizeof(rb), "--");
}
IR("LoRa RSSI", rb);
IR("LoRa Status",
!hasLiveData ? "Waiting for data"
: (loraLost ? "Signal lost"
: "Online"));
IR("Live Data", hasLiveData ? "Active" : "Waiting");
}
H("</div></div>");
H("<div class='sec-hdr'>Help</div>");
H("<div style='max-width:480px'><div class='card'>"
"<p style='font-size:13px;color:var(--sub);line-height:1.9'>"
"• Pages update via /api/data fetch polling — no full reloads.<br>"
"• Admin controls relay manually. No automatic cut-off.<br>"
"• Monthly kWh resets automatically on the 1st of each month.<br>"
"• Unit price is saved in flash memory across reboots.<br>"
"• LoRa signal loss freezes last known values with a warning.<br>"
"• Values show -- until the first valid LoRa packet is received."
"</p></div></div>");
H("<div class='sec-hdr'>Session</div>");
H("<div style='max-width:480px'><div class='card'>"
"<p style='font-size:13px;color:var(--sub);margin-bottom:14px'>"
"End your current session securely.</p>"
"<button class='btn btn-danger' onclick=\"location='/out'\">Logout Now</button>"
"</div></div>");
pageFoot();
}
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// LoRa PACKET VALIDATOR + PARSER
// Format: TX,<id>,<V>,<A>,<W>,<Hz>,<kWhtotal>,<kWhmonthly>,<kWhprevday>,<relay>
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
bool parseLoRaPacket(const String& raw) {
if (!raw.startsWith("TX,")) return false;
// Require at least 9 commas (10 fields)
uint8_t commas = 0;
for (size_t i = 0; i < raw.length(); i++) {
if (raw[i] == ',') commas++;
}
if (commas < 9) return false;
char buf[256];
raw.toCharArray(buf, sizeof(buf));
char* tok;
tok = strtok(buf, ","); if (!tok) return false; // "TX"
tok = strtok(NULL, ","); if (!tok) return false; // device id
tok = strtok(NULL, ","); if (!tok) return false;
float v = atof(tok);
if (v < 50.0f || v > 500.0f) return false; // voltage sanity
ed.voltage = v;
tok = strtok(NULL, ","); if (!tok) return false;
float a = atof(tok);
if (a < 0.0f || a > 100.0f) return false; // current sanity
ed.current = a;
tok = strtok(NULL, ","); if (!tok) return false;
float w = atof(tok);
if (w < 0.0f || w > 25000.0f) return false; // power sanity
ed.power = w;
tok = strtok(NULL, ","); if (!tok) return false;
float f = atof(tok);
if (f < 40.0f || f > 70.0f) return false; // frequency sanity
ed.freq = f;
tok = strtok(NULL, ","); if (!tok) return false;
ed.total = atof(tok);
tok = strtok(NULL, ","); if (!tok) return false;
ed.monthly = atof(tok);
tok = strtok(NULL, ","); if (!tok) return false;
ed.prevDay = atof(tok);
tok = strtok(NULL, ","); if (!tok) return false;
ed.relay = (atoi(tok) == 1);
return true;
}
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// OLED β yellow zone (y 0-15): title | blue zone (y 16-63): IP+RSSI
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
void updateOLED() {
lastOledRefresh = millis();
oled.clearDisplay();
oled.setTextColor(SSD1306_WHITE);
oled.setTextSize(1);
// Yellow zone β centered title
oled.setCursor(22, 4);
oled.print(F("ENERGY MONITOR"));
oled.drawLine(0, 15, 127, 15, SSD1306_WHITE);
// Blue zone β IP
oled.setCursor(0, 21);
oled.print(F("IP:"));
oled.setCursor(20, 21);
if (WiFi.status() == WL_CONNECTED) {
oled.print(WiFi.localIP());
} else {
oled.print(F("Connecting..."));
}
// Blue zone β RSSI
oled.setCursor(0, 38);
oled.print(F("RSSI:"));
oled.setCursor(34, 38);
if (hasLiveData) {
oled.print(ed.rssi);
oled.print(F(" dBm"));
} else {
oled.print(F("--"));
}
// Status line
oled.setCursor(0, 54);
if (loraLost) {
oled.print(F("! Signal lost"));
} else if (!hasLiveData) {
oled.print(F("Waiting for data"));
}
oled.display();
}
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// LoRa TX β send command to transmitter
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
void sendLoRa(const char* type, float val) {
if (!loRaActive) return;
LoRa.beginPacket();
LoRa.print(type);
LoRa.print(',');
LoRa.print(val, 2);
LoRa.endPacket();
}
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// MONTHLY RESET β NTP timestamp based
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
void checkMonthlyReset() {
struct tm t;
if (!getLocalTime(&t)) return;
uint8_t curMonth = (uint8_t)t.tm_mon;
if (lastResetMonth == 255) {
lastResetMonth = curMonth;
return;
}
if (curMonth != lastResetMonth) {
// Shift monthly history buffer
for (int i = 0; i < 5; i++) monHist[i] = monHist[i + 1];
monHist[5] = ed.monthly;
ed.monthly = 0.0f;
billPaid = false;
lastResetMonth = curMonth;
prefs.begin("energy", false);
prefs.putFloat("monthly", 0.0f);
prefs.putBool ("paid", false);
prefs.putUChar("resetmon", curMonth);
prefs.end();
Serial.println(F("[BILLING] Monthly reset done."));
}
}
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// AUTH HELPERS
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
bool checkAuth() {
if (!server.hasHeader("Cookie")) return false;
String cookie = server.header("Cookie");
int idx = cookie.indexOf("SES=");
if (idx == -1) return false;
String tok = cookie.substring(idx + 4, idx + 20);
if (tok == String(sessionToken) && millis() < sessionExp) {
sessionExp = millis() + 3600000UL; // sliding window
return true;
}
return false;
}
void redirectLogin() {
server.sendHeader("Location", "/login");
server.send(303);
}
void sendForbidden() {
server.send(403, F("application/json"), F("{\"error\":\"forbidden\"}"));
}
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// SETUP
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
void setup() {
Serial.begin(115200);
delay(600);
Serial.println(F("\n[BOOT] Postpaid Energy Meter v3.0"));
// ββ OLED βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Wire.begin(OLED_SDA, OLED_SCL);
if (!oled.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR)) {
Serial.println(F("[OLED] FAIL"));
} else {
oled.clearDisplay();
oled.setTextColor(SSD1306_WHITE);
oled.setTextSize(1);
oled.setCursor(22, 4);
oled.print(F("ENERGY MONITOR"));
oled.drawLine(0, 15, 127, 15, SSD1306_WHITE);
oled.setCursor(0, 24);
oled.print(F("Booting..."));
oled.display();
Serial.println(F("[OLED] OK"));
}
// ββ Preferences β ONLY unitPrice loaded ββββββββββββββββοΏ½οΏ½οΏ½βββββββββ
prefs.begin("energy", true);
unitPrice = prefs.getFloat("unitprice", 8.0f);
billPaid = prefs.getBool ("paid", false);
lastResetMonth = prefs.getUChar("resetmon", 255);
prefs.end();
Serial.printf("[PREFS] unitPrice=%.2f\n", unitPrice);
// NOTE: total / monthly / prevDay intentionally NOT loaded.
// All energy values start at 0 and populate from LoRa only.
// ββ LoRa βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
loRaSPI.begin(LORA_SCK, LORA_MISO, LORA_MOSI, LORA_NSS);
LoRa.setSPI(loRaSPI);
LoRa.setPins(LORA_NSS, LORA_RST, LORA_DIO0);
if (LoRa.begin(LORA_FREQ)) {
LoRa.setSpreadingFactor(LORA_SF);
LoRa.setSignalBandwidth(LORA_BW);
LoRa.setCodingRate4(5);
LoRa.enableCrc();
loRaActive = true;
Serial.println(F("[LoRa] OK"));
} else {
Serial.println(F("[LoRa] FAIL - WiFi-only mode"));
}
// ββ WiFi βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(true);
WiFi.begin(WIFI_SSID, WIFI_PASS);
Serial.print(F("[WiFi] Connecting"));
uint8_t tries = 0;
while (WiFi.status() != WL_CONNECTED && tries++ < 40) {
delay(300);
Serial.print('.');
if (tries % 6 == 0) {
oled.clearDisplay();
oled.setCursor(22, 4);
oled.print(F("ENERGY MONITOR"));
oled.drawLine(0, 15, 127, 15, SSD1306_WHITE);
oled.setCursor(0, 24);
oled.print(F("WiFi connecting..."));
oled.setCursor(0, 38);
oled.print(tries / 6);
oled.print(F(" attempts"));
oled.display();
}
}
Serial.println();
if (WiFi.status() == WL_CONNECTED) {
Serial.print(F("[WiFi] IP: "));
Serial.println(WiFi.localIP());
} else {
Serial.println(F("[WiFi] Failed - check credentials"));
}
// ββ NTP ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
configTime(GMT_OFFSET_SEC, DST_OFFSET_SEC, NTP_SERVER);
Serial.println(F("[NTP] Sync requested"));
// ββ Initial OLED βββββββββββββββββββββββββββββββββββββββββββββββββ
updateOLED();
// ββ Web Server Routes βββββββββββββββββββββββββββββββββββββββββββββ
const char* hdrs[] = {"Cookie"};
server.collectHeaders(hdrs, 1);
// /login
server.on("/login", HTTP_GET, []() {
String page = FPSTR(LOGIN_HTML);
page.replace("__ERR__", "");
server.send(200, F("text/html"), page);
});
// /auth
server.on("/auth", HTTP_POST, []() {
String u = server.arg("u");
String p = server.arg("p");
bool adm = (u == ADMIN_ID && p == ADMIN_PASS);
bool usr = (u == USER_ID && p == USER_PASS);
if (adm || usr) {
uint32_t r1 = esp_random(), r2 = esp_random();
snprintf(sessionToken, sizeof(sessionToken),
"%08lx%08lx", (unsigned long)r1, (unsigned long)r2);
sessionExp = millis() + 3600000UL;
isAdminSession = adm;
server.sendHeader("Location", adm ? "/admin" : "/");
server.sendHeader("Set-Cookie",
String("SES=") + sessionToken + "; Path=/; HttpOnly");
server.send(303);
Serial.printf("[AUTH] OK: %s (%s)\n",
u.c_str(), adm ? "admin" : "user");
} else {
String page = FPSTR(LOGIN_HTML);
page.replace("__ERR__",
"<div class='err'>Invalid credentials. Please try again.</div>");
server.send(200, F("text/html"), page);
Serial.println(F("[AUTH] Failed attempt"));
}
});
// /out
server.on("/out", HTTP_GET, []() {
memset(sessionToken, 0, sizeof(sessionToken));
sessionExp = 0;
server.sendHeader("Set-Cookie", "SES=; Path=/; Max-Age=0");
server.sendHeader("Location", "/login");
server.send(303);
});
// / (user home)
server.on("/", HTTP_GET, []() {
if (!checkAuth()) { redirectLogin(); return; }
g_html = "";
buildUserHome();
server.send(200, F("text/html"), g_html);
});
// /usage
server.on("/usage", HTTP_GET, []() {
if (!checkAuth()) { redirectLogin(); return; }
g_html = "";
buildUserUsage();
server.send(200, F("text/html"), g_html);
});
// /bill
server.on("/bill", HTTP_GET, []() {
if (!checkAuth()) { redirectLogin(); return; }
g_html = "";
buildUserBill();
server.send(200, F("text/html"), g_html);
});
// /settings
server.on("/settings", HTTP_GET, []() {
if (!checkAuth()) { redirectLogin(); return; }
g_html = "";
buildSettings();
server.send(200, F("text/html"), g_html);
});
// /admin (table or detail)
server.on("/admin", HTTP_GET, []() {
if (!checkAuth() || !isAdminSession) { redirectLogin(); return; }
g_html = "";
if (server.hasArg("id") && server.arg("id") == DEVICE_ID) {
buildAdminDetail();
} else {
buildAdminTable();
}
server.send(200, F("text/html"), g_html);
});
// /admin/unitprice
server.on("/admin/unitprice", HTTP_GET, []() {
if (!checkAuth() || !isAdminSession) { redirectLogin(); return; }
g_html = "";
buildUnitPrice();
server.send(200, F("text/html"), g_html);
});
// /api/data β JSON polling endpoint
server.on("/api/data", HTTP_GET, []() {
if (!checkAuth()) {
server.send(401, F("application/json"),
F("{\"error\":\"unauthorized\"}"));
return;
}
char jbuf[400];
if (!hasLiveData) {
snprintf(jbuf, sizeof(jbuf),
"{\"hasLiveData\":false,\"loraLost\":false,"
"\"voltage\":0,\"current\":0,\"power\":0,\"freq\":0,"
"\"total\":0,\"monthly\":0,\"prevDay\":0,"
"\"relay\":true,\"rssi\":0,"
"\"unitPrice\":%.2f,\"bill\":0,\"paid\":false}",
unitPrice);
} else {
snprintf(jbuf, sizeof(jbuf),
"{\"hasLiveData\":true,\"loraLost\":%s,"
"\"voltage\":%.1f,\"current\":%.2f,"
"\"power\":%.1f,\"freq\":%.1f,"
"\"total\":%.3f,\"monthly\":%.2f,\"prevDay\":%.2f,"
"\"relay\":%s,\"rssi\":%d,"
"\"unitPrice\":%.2f,\"bill\":%.2f,\"paid\":%s}",
loraLost ? "true" : "false",
ed.voltage, ed.current, ed.power, ed.freq,
ed.total, ed.monthly, ed.prevDay,
ed.relay ? "true" : "false",
ed.rssi,
unitPrice,
ed.monthly * unitPrice,
billPaid ? "true" : "false");
}
server.sendHeader("Cache-Control", "no-cache, no-store");
server.send(200, F("application/json"), jbuf);
});
// /api/relay
server.on("/api/relay", HTTP_POST, []() {
if (!checkAuth() || !isAdminSession) { sendForbidden(); return; }
if (!server.hasArg("s")) {
server.send(400, F("application/json"),
F("{\"error\":\"missing s\"}"));
return;
}
int s = server.arg("s").toInt();
if (s != 0 && s != 1) {
server.send(400, F("application/json"),
F("{\"error\":\"s must be 0 or 1\"}"));
return;
}
ed.relay = (s == 1);
sendLoRa("RELAY", (float)s);
Serial.printf("[RELAY] -> %s\n", ed.relay ? "ON" : "OFF");
server.send(200, F("application/json"), F("{\"ok\":true}"));
});
// /api/unitprice
server.on("/api/unitprice", HTTP_POST, []() {
if (!checkAuth() || !isAdminSession) { sendForbidden(); return; }
if (!server.hasArg("price")) {
server.send(400, F("application/json"),
F("{\"error\":\"missing price\"}"));
return;
}
float p = server.arg("price").toFloat();
if (p <= 0.0f || p > 100.0f) {
server.send(400, F("application/json"),
F("{\"error\":\"price out of range\"}"));
return;
}
unitPrice = p;
prefs.begin("energy", false);
prefs.putFloat("unitprice", unitPrice);
prefs.end();
Serial.printf("[PRICE] Rs%.2f/kWh\n", unitPrice);
char resp[48];
snprintf(resp, sizeof(resp),
"{\"ok\":true,\"unitPrice\":%.2f}", unitPrice);
server.send(200, F("application/json"), resp);
});
// /api/billstatus
server.on("/api/billstatus", HTTP_POST, []() {
if (!checkAuth() || !isAdminSession) { sendForbidden(); return; }
if (!server.hasArg("s")) {
server.send(400, F("application/json"),
F("{\"error\":\"missing s\"}"));
return;
}
billPaid = (server.arg("s").toInt() == 1);
prefs.begin("energy", false);
prefs.putBool("paid", billPaid);
prefs.end();
Serial.printf("[BILL] -> %s\n", billPaid ? "PAID" : "UNPAID");
char resp[42];
snprintf(resp, sizeof(resp),
"{\"ok\":true,\"paid\":%s}",
billPaid ? "true" : "false");
server.send(200, F("application/json"), resp);
});
// 404 catch-all
server.onNotFound([]() {
server.sendHeader("Location",
checkAuth() ? (isAdminSession ? "/admin" : "/") : "/login");
server.send(303);
});
server.begin();
Serial.println(F("[SERVER] Running on port 80"));
updateOLED();
}
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// LOOP
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
void loop() {
// 1. HTTP clients
server.handleClient();
// 2. LoRa RX β receive, validate, parse
if (loRaActive) {
int pktSize = LoRa.parsePacket();
if (pktSize > 0 && pktSize < 256) {
String raw = "";
raw.reserve(pktSize + 1);
while (LoRa.available()) raw += (char)LoRa.read();
Serial.print(F("[LoRa] RX: "));
Serial.println(raw);
if (parseLoRaPacket(raw)) {
ed.rssi = LoRa.packetRssi();
ed.lastRx = millis();
if (!hasLiveData) {
hasLiveData = true;
loraLost = false;
Serial.println(F("[LoRa] First valid packet - live data active"));
updateOLED();
}
if (loraLost) {
loraLost = false;
Serial.println(F("[LoRa] Signal recovered"));
updateOLED();
}
// Update today's slot in day history
dayHist[6] = ed.prevDay;
Serial.printf(
"[DATA] V=%.1f A=%.2f W=%.1f Hz=%.1f "
"tot=%.3f mo=%.2f pd=%.2f rssi=%d\n",
ed.voltage, ed.current, ed.power, ed.freq,
ed.total, ed.monthly, ed.prevDay, ed.rssi);
// Throttled Preferences save β never save zeros
uint32_t now = millis();
if ((now - lastPrefSave) > PREF_SAVE_INTERVAL_MS) {
lastPrefSave = now;
if (ed.total > 0.0f || ed.monthly > 0.0f) {
prefs.begin("energy", false);
prefs.putFloat("total", ed.total);
prefs.putFloat("monthly", ed.monthly);
prefs.putFloat("prevday", ed.prevDay);
prefs.putFloat("unitprice", unitPrice);
prefs.end();
Serial.println(F("[PREFS] Saved."));
}
}
updateOLED();
} else {
Serial.println(F("[LoRa] Packet rejected - validation failed"));
}
}
}
// 3. LoRa fail-safe β detect signal loss (only after first packet)
if (loRaActive && hasLiveData && !loraLost) {
if ((millis() - ed.lastRx) > LORA_TIMEOUT_MS) {
loraLost = true;
Serial.println(F("[LoRa] SIGNAL LOST - values frozen"));
updateOLED();
}
}
// 4. OLED keep-alive (flicker-free, only when due)
if ((millis() - lastOledRefresh) > OLED_REFRESH_MS) {
updateOLED();
}
// 5. Monthly billing reset check
if ((millis() - lastMonthCheck) > MONTH_CHECK_MS) {
lastMonthCheck = millis();
checkMonthlyReset();
}
// 6. WiFi watchdog
if ((millis() - lastWiFiCheck) > WIFI_WATCH_MS) {
lastWiFiCheck = millis();
if (WiFi.status() != WL_CONNECTED) {
Serial.println(F("[WiFi] Disconnected - reconnecting..."));
WiFi.reconnect();
}
}
}Step 8: Software and Libraries SetupTo upload code to the ESP32, you need the Arduino IDE with ESP32 board support and the required libraries installed.
πΉ Install ESP32 Board Support- Connect your ESP32 to the computer using a USB cable.
- Open Device Manager and confirm it appears as CP210x USB to UART (COMx).
Now install ESP32 support:
β’ Open Arduino IDE β File β Preferences
β’ In Additional Boards Manager URLs, add:
https://dl.espressif.com/dl/package_esp32_index.json
β’ Go to Tools β Board β Boards Manager
β’ Search ESP32 by Espressif Systems and install
After installation:
Go to Tools β Board β ESP32 Dev Module
πΉ Install Required LibrariesBefore uploading the code, install these libraries:
β’ LoRa
β’ Adafruit GFX
β’ Adafruit SSD1306
β’ Adafruit ST7735
β’ PZEM004Tv30
Install from:
Tools β Manage Libraries
πΉ Upload and Testβ’ Open transmitter or receiver code
β’ Select correct board and COM port
β’ Click Upload
After upload:
Transmitter β TFT shows readings
Receiver β OLED shows IP address
Open browser β enter IP β login and test dashboard + relay control.
Step 9: Print and Assemble EnclosurePrint the enclosure and assemble both the transmitter and receiver units inside the case. This keeps the setup compact, safe, and more professional.
πΉ Step 1 β Print the Enclosure- Download the enclosure STL file attached to this step.
- Open your slicer software (Cura, Bambu Studio, PrusaSlicer, etc.).
- Import the STL file and select your printer profile.
Recommended settings:
β’ Material: PLA
β’ Layer height: 0.2 mm
β’ Infill: 20β40%
β’ Supports: Not required
- Slice and export the file.
- Start printing and wait until complete.
The enclosure includes cutouts for the display, antenna, and cable routing as shown in the images.
πΉ Step 2 β Install the Antenna- Insert the SMA antenna connector into the side hole of the case.
- Tighten it securely using the nut.
- Attach the antenna to the connector from outside.
This ensures stable LoRa signal performance.
πΉ Step 3 β Fit the Circuit BoardsTransmitter Unit- Place the perfboard assembly inside the enclosure carefully.
- Align the TFT display with the front opening.
- Ensure the relay and PZEM module sit properly without pressure ( I use double sided tape for this).
Secure the board using:
β’ Hot glue (recommended)
or
β’ Double-sided tape (temporary setup)
Receiver Unit- Place the receiver perfboard into the enclosure.
- Align the OLED screen properly with the opening.
- Route the antenna wire neatly inside the box.
πΉ Step 4 β Cable Management
β’ Arrange wires neatly to avoid pressure points
β’ Keep mains wiring separated from signal wiring
β’ Ensure nothing touches sharp edges
πΉ Step 5 β Final Check
Before closing:
β’ Confirm displays align properly
β’ Check antenna connection
β’ Ensure no loose wires
I did not design a lid for this enclosure since I am still learning 3D printing. You can redesign or customize the lid according to your setup.
Testing, Demo & VerificationPower both transmitter and receiver units.
Check:
β’ Transmitter TFT turns ON and shows readings
β’ Receiver OLED shows WiFi IP address
β’ No overheating or unusual behavior
If everything looks stable, continue.
πΉ Step 2 β LoRa Communication TestKeep both units near each other first.
Check:
β’ Receiver starts showing live data
β’ OLED displays RSSI value
RSSI closer to 0 (example β50) = stronger signal than β100.
πΉ Step 3 β Dashboard Test- Connect phone/laptop to same WiFi network.
- Open browser and enter receiver IP.
Test:
β’ Live values update smoothly
β’ Charts load properly
β’ Relay ON/OFF works instantly
Web Dashboard Testing & DemoIn this step, we test both Admin and User interfaces of the Energy Meter web dashboard. This confirms real-time monitoring, billing calculation, relay control, and system stability.
Login Test (Admin + User)
Open a browser and enter the IP address shown on the OLED screen.
Login credentials:
β’ Admin β admin / admin123
β’ User β user1 / pass123
After login, the dashboard loads automatically depending on the role.
Admin Dashboard Overview
The admin dashboard provides full system monitoring and control:
β’ Total connected devices
β’ Monthly consumption and billing
β’ Device connection status
β’ Monthly usage charts
Data updates automatically without refreshing the page.
πΉ Device Detail MonitoringOpen the device detail page to verify live readings.
Check:
β’ Voltage updates continuously
β’ Current changes with load
β’ Power and frequency remain stable
β’ Consumption values calculate correctly
This confirms accurate PZEM readings and stable LoRa transfer.
πΉ Relay Control TestingTest remote switching:
β’ Click Cut Power / Restore Power
β’ Relay responds instantly
β’ Load turns ON/OFF properly
This confirms bidirectional LoRa communication.
πΉ Billing and Unit Price ConfigurationTest billing features:
β’ Change unit price
β’ Save settings
β’ Monthly bill updates automatically
πΉ User Dashboard TestingThe user dashboard allows simple monitoring:
β’ Live voltage/current/power
β’ Monthly usage
β’ Billing status
β’ Weekly trend chart
Users cannot control relay or settings.
Check system status:
β’ Device ID
β’ Firmware version
β’ IP address
β’ LoRa RSSI
β’ Live connection status
Now the system is ready for real field testing. In my setup, I used a Hi-Link HLK-PM01 AC-to-5V module, so an external 5V adapter was not needed. The AC mains was first connected to the Hi-Link module, which safely converted it into regulated 5V to power the ESP32 transmitter.
After that, the AC line was connected to the PZEM004T exactly according to the wiring diagram. The phase wire was passed through the PZEM input/output terminals, while the neutral wire was connected directly to the load.
Once powered ON, the TFT immediately started showing live readings, and the receiver OLED displayed the IP address along with connection status. Opening the web dashboard confirmed that voltage, current, and power values were updating smoothly in real time.
For testing, I used a simple 10W LED bulb as the load and operated the system mainly during one night. Because the load was small and used for a short time, the energy consumption values shown in the dashboard are low. However, the goal of this test was to verify system stability, communication, and accuracy rather than long-term measurement.
During testing, the transmitter measured voltage, current, power, and frequency correctly. The receiver displayed all readings accurately with smooth updates. Even with a small load, the readings remained stable and consistent.
Overall, the system performed well and delivered accurate results for the test conditions. Although this test used only a 10W bulb, the setup is fully capable of handling larger loads and long-term monitoring in real applications.




_ztBMuBhMHo.jpg?auto=compress%2Cformat&w=48&h=48&fit=fill&bg=ffffff)






_t9PF3orMPd.png?auto=compress%2Cformat&w=40&h=40&fit=fillmax&bg=fff&dpr=2)






Comments