Raushan kr.
Published © MIT

IoT Energy Meter With ESP32, LoRa - Web Monitoring

IoT energy monitoring system using ESP32, LoRa, and a custom web dashboard. It measures voltage, current, power, and energy in real time.

IntermediateFull instructions provided6 hours69
IoT Energy Meter With ESP32, LoRa - Web Monitoring

Things used in this project

Hardware components

Espressif ESP32 Development Board - Developer Edition
Espressif ESP32 Development Board - Developer Edition
×1
LoRa SX1278 Module
×1
PZEM004T v3.0 Energy Meter Module
×1
1.8 ST7735 TFT Display
×1
0.96" OLED 64x128 Display Module
ElectroPeak 0.96" OLED 64x128 Display Module
×1
Arduino UNO
Arduino UNO
×1

Software apps and online services

Arduino IDE
Arduino IDE
Fusion
Autodesk Fusion

Hand tools and fabrication machines

3D Printer (generic)
3D Printer (generic)

Story

Read more

Custom parts and enclosures

pzem-004t_case_X1xZdrsVP8.stl

pzem-004t_lid_aejuUmk2MZ.stl

brave_stantia_AuXNUa7h8W.stl

Schematics

lora_rx_circuit_4LoUNAx5CH.png

lora_rx_KlCl4BoaQl.png

Code

Transmitter code

Arduino
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 Code

Arduino
#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">&#9889;</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 &middot; LoRa 433 MHz &middot; 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'>&#9889;</span>"
    "<div><div class='logo-text'>Energy Meter</div>"
    "<div class='sidebar-ver'>v3.0 &middot; " 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'>&#128202;</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'>&#128178;</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'>&#9881;</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'>&#127968;</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'>&#128200;</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'>&#129534;</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'>&#9881;</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'>&#9211;</span>"
    "<span class='nav-label'> Logout</span></div>");
  H("</nav>");

  // Topbar
  H("<header id='topbar'>"
    "<button class='btn-icon' onclick='toggleSidebar()'>&#9776;</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()'>&#127769; Dark</button>"
    "<button class='btn-icon' onclick=\"location='/out'\">&#9211;</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'>&#10003; Paid</span>";
  return "<span class='badge badge-red'>&#10007; Unpaid</span>";
}

// ────────────────────────────────────────────────────────────────────
//  SHARED: ALERT BANNERS
// ────────────────────────────────────────────────────────────────────
void emitAlerts() {
  if (!hasLiveData) {
    H("<div class='alert alert-blue fade-in'>"
      "<span style='font-size:20px'>&#128225;</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'>&#9888;</span>"
      "<div><strong>LoRa signal lost</strong> &mdash; 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'>&#129534;</span>"
        "<div><strong>Bill unpaid</strong> &mdash; &#8377;");
      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'>&#10003; Paid</span>\""
    ":\"<span class='badge badge-red'>&#10007; 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'>&#127981;</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'>&#9889;</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'>&#128176;</div>"
    "<div class='card-lbl'>Monthly Bill</div>"
    "<div class='card-val'>");
  H(hasLiveData ? "&#8377;" + String(bill, 2) : "--");
  H("</div><div class='card-sub'>@ &#8377;");
  H(unitPrice, 2);
  H("/kWh</div></div>");

  H("<div class='card'><div class='card-icon'>&#129534;</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 ? "&#8377;" + 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> &rsaquo; "
    "Device " DEVICE_ID "</p>"
    "<h1>Device " DEVICE_ID " &mdash; 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[] = {
    {"&#128268;","Voltage",   "lv-v",  valOrDash(ed.voltage, 1) + " V",   "Nominal 230 V"},
    {"&#12316;", "Current",   "lv-a",  valOrDash(ed.current, 2) + " A",   "Load current" },
    {"&#128161;","Power",     "lv-w",  valOrDash(ed.power,   1) + " W",   "Active power" },
    {"&#128260;","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[] = {
    {"&#128197;","Previous Day",   "lv-pd",  valOrDash(ed.prevDay, 2) + " kWh", "Yesterday"},
    {"&#128198;","This Month",     "lv-mo",  valOrDash(ed.monthly, 2) + " kWh", "Billing period"},
    {"&#128193;","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'>&#128178;</div>"
    "<div class='card-lbl'>Unit Price</div>"
    "<div class='card-val accent'>&#8377;");
  H(unitPrice, 2);
  H("/kWh</div><div class='card-sub'>Admin configurable</div></div>");

  H("<div class='card'><div class='card-icon'>&#129534;</div>"
    "<div class='card-lbl'>Monthly Bill</div>"
    "<div class='card-val' id='lv-bill'>");
  H(hasLiveData ? "&#8377;" + 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'>&#9989;</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'>&#128268;</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'>&#127899;</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'>&#128246;</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'>&#128178;</div>"
    "<div class='card-lbl'>Current Price</div>"
    "<div class='card-val green'>&#8377;");
  H(unitPrice, 2);
  H("/kWh</div></div>");

  H("<div class='card'><div class='card-icon'>&#129534;</div>"
    "<div class='card-lbl'>Current Monthly Bill</div>"
    "<div class='card-val'>");
  H(hasLiveData ? "&#8377;" + String(ed.monthly * unitPrice, 2) : "--");
  H("</div><div class='card-sub'>");
  H(hasLiveData
    ? String(ed.monthly, 2) + " kWh x &#8377;" + 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 (&#8377; 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>&#8377;");
    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>&#8377;");
    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'>&#128198;</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'>&#128176;</div>"
    "<div class='card-lbl'>Monthly Bill</div>"
    "<div class='card-val' id='lv-bill'>");
  H(hasLiveData ? "&#8377;" + String(bill, 2) : "--");
  H("</div><div class='card-sub'>@ &#8377;"); H(unitPrice, 2); H("/kWh</div></div>");

  H("<div class='card'><div class='card-icon'>&#128202;</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'>&#129534;</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[] = {
    {"&#128268;","Voltage",   "lv-v", valOrDash(ed.voltage, 1)+" V",  "Nominal 230 V"},
    {"&#12316;", "Current",   "lv-a", valOrDash(ed.current, 2)+" A",  "Load current"},
    {"&#128161;","Power",     "lv-w", valOrDash(ed.power,   1)+" W",  "Active power"},
    {"&#128260;","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[] = {
    {"&#128197;","Previous Day",   "lv-pd",  valOrDash(ed.prevDay, 2)+" kWh","Yesterday"},
    {"&#128198;","This Month",     "lv-mo",  valOrDash(ed.monthly, 2)+" kWh","Billing period"},
    {"&#128193;","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>");
    }
...

This file has been truncated, please download it to see its full contents.

Credits

Raushan kr.
36 projects β€’ 162 followers
Maker | Developer | Content Creator

Comments