/*
* ============================================================
* ESP32 AC Energy Monitor
* Features:
* - RMS Voltage & Current measurement
* - Power Factor estimation (via zero-cross phase shift)
* - Active / Apparent / Reactive Power
* - LCD 16x2 I2C display (rotating pages)
* - Wi-Fi Web Dashboard (auto-refresh)
* ============================================================
*
* WIRING SUMMARY
* ──────────────────────────────────────────────────────────
* Voltage sensor (ZMPT101B) → GPIO 35 (ADC1_CH7)
* Current sensor (ACS712) → GPIO 34 (ADC1_CH6)
* LCD SDA → GPIO 21
* LCD SCL → GPIO 22
* LCD I2C address → 0x27 (change if needed)
*
* LIBRARIES REQUIRED (install via Arduino Library Manager)
* ──────────────────────────────────────────────────────────
* - LiquidCrystal_I2C by Frank de Brabander
* - WiFi (built-in ESP32)
* - WebServer (built-in ESP32)
* ============================================================
*/
#include <Wire.h>
#include <LiquidCrystal_I2C.h>
#include <WiFi.h>
#include <WebServer.h>
#include <math.h>
// ─────────────────────────────────────────
// Wi-Fi Credentials ← CHANGE THESE
// ─────────────────────────────────────────
const char* ssid = "POCO C71";
const char* password = "12345666";
// ─────────────────────────────────────────
// Pin Definitions
// ─────────────────────────────────────────
#define VOLTAGE_PIN 35
#define CURRENT_PIN 34
// ─────────────────────────────────────────
// Sensor Calibration
// ─────────────────────────────────────────
float voltageCal = 510.0; // ZMPT101B calibration factor
float sensitivity = 0.1; // ACS712-5A: 0.185 V/A | 20A: 0.1 | 30A: 0.066
float currentCalFactor = 0.45; // Fine-tune until ammeter matches
// ─────────────────────────────────────────
// Offset variables (calculated at boot)
// ─────────────────────────────────────────
float offsetV = 0;
float offsetI = 0;
// ─────────────────────────────────────────
// Output variables (smoothed)
// ─────────────────────────────────────────
float voltage = 0;
float current = 0;
float activePower = 0;
float apparentPower= 0;
float reactivePower= 0;
float powerFactor = 0;
float energyWh = 0; // accumulated energy in Wh
// ─────────────────────────────────────────
// LCD — 16 columns, 2 rows, I2C addr 0x27
// ─────────────────────────────────────────
LiquidCrystal_I2C lcd(0x27, 16, 2);
// ─────────────────────────────────────────
// Web Server on port 80
// ─────────────────────────────────────────
WebServer server(80);
// ─────────────────────────────────────────
// Timing
// ─────────────────────────────────────────
unsigned long lastMeasureMs = 0;
unsigned long lastLCDMs = 0;
unsigned long startMs = 0;
int lcdPage = 0;
// ═══════════════════════════════════════════════════════════
// SETUP
// ═══════════════════════════════════════════════════════════
void setup() {
Serial.begin(115200);
// ── LCD init ──
lcd.init();
lcd.backlight();
lcd.setCursor(0, 0); lcd.print(" Energy Monitor");
lcd.setCursor(0, 1); lcd.print(" Initialising..");
delay(1000);
// ── Offset calibration ──
lcd.clear();
lcd.setCursor(0, 0); lcd.print("Calibrating...");
lcd.setCursor(0, 1); lcd.print("Remove all loads");
Serial.println("Calculating Offsets (NO AC & NO LOAD)...");
for (int i = 0; i < 1500; i++) {
offsetV += analogRead(VOLTAGE_PIN) * (3.3 / 4095.0);
delayMicroseconds(200);
}
offsetV /= 1500;
for (int i = 0; i < 1500; i++) {
offsetI += analogRead(CURRENT_PIN) * (3.3 / 4095.0);
delayMicroseconds(200);
}
offsetI /= 1500;
Serial.print("offsetV: "); Serial.println(offsetV, 4);
Serial.print("offsetI: "); Serial.println(offsetI, 4);
// ── Wi-Fi ──
lcd.clear();
lcd.setCursor(0, 0); lcd.print("Connecting WiFi");
lcd.setCursor(0, 1); lcd.print(ssid);
WiFi.begin(ssid, password);
int tries = 0;
while (WiFi.status() != WL_CONNECTED && tries < 30) {
delay(500);
Serial.print(".");
tries++;
}
if (WiFi.status() == WL_CONNECTED) {
Serial.println("\nWiFi connected: " + WiFi.localIP().toString());
lcd.clear();
lcd.setCursor(0, 0); lcd.print("WiFi connected!");
lcd.setCursor(0, 1); lcd.print(WiFi.localIP().toString());
delay(2000);
} else {
Serial.println("\nWiFi FAILED — running offline");
lcd.clear();
lcd.setCursor(0, 0); lcd.print("WiFi failed.");
lcd.setCursor(0, 1); lcd.print("Offline mode.");
delay(2000);
}
// ── Web routes ──
server.on("/", handleRoot);
server.on("/data", handleData);
server.on("/reset", handleReset);
server.begin();
startMs = millis();
Serial.println("Ready.");
}
// ═══════════════════════════════════════════════════════════
// LOOP
// ═══════════════════════════════════════════════════════════
void loop() {
server.handleClient();
unsigned long now = millis();
// ── Measure every 1 second ──
if (now - lastMeasureMs >= 1000) {
lastMeasureMs = now;
takeMeasurement();
printSerial();
}
// ── Rotate LCD page every 3 seconds ──
if (now - lastLCDMs >= 3000) {
lastLCDMs = now;
updateLCD();
lcdPage = (lcdPage + 1) % 3;
}
}
// ═══════════════════════════════════════════════════════════
// MEASUREMENT
//
// KEY FIX: V and I are read in the SAME loop iteration so
// they are truly simultaneous. Active power = mean(v * i)
// then scaled using the same factors as Vrms and Irms.
//
// Active Power P = mean(vScaled * iScaled)
// = mean(vRaw * voltageCal * iRaw * (calFactor/sensitivity))
// = meanP * voltageCal * (calFactor / sensitivity)
//
// This gives Watts directly — no guesswork.
// ═══════════════════════════════════════════════════════════
void takeMeasurement() {
const int samples = 600;
float sumV = 0, sumI = 0, sumP = 0;
for (int i = 0; i < samples; i++) {
// ── Read BOTH pins in the same iteration (simultaneous) ──
float vRaw = analogRead(VOLTAGE_PIN) * (3.3 / 4095.0) - offsetV;
float iRaw = analogRead(CURRENT_PIN) * (3.3 / 4095.0) - offsetI;
sumV += vRaw * vRaw;
sumI += iRaw * iRaw;
sumP += vRaw * iRaw; // true instantaneous power accumulation
}
float rmsV = sqrt(sumV / samples);
float rmsI = sqrt(sumI / samples);
float meanP = sumP / samples;
// ── Voltage (V) ──
float newVoltage = 0;
if (rmsV < 0.1) {
voltage = 0;
} else {
newVoltage = rmsV * voltageCal;
if (newVoltage > 260) newVoltage = voltage; // spike reject
if (newVoltage < 180) newVoltage = 0; // noise floor
voltage = voltage * 0.9 + newVoltage * 0.1;
}
// ── Current (A) ──
float rawCurrent = (rmsI / sensitivity) * currentCalFactor;
if (voltage < 10.0) {
// No AC present — zero everything
current = 0;
activePower = 0;
apparentPower = 0;
reactivePower = 0;
powerFactor = 0;
return;
}
if (rawCurrent < 0.15) rawCurrent = 0;
current = current * 0.7 + rawCurrent * 0.3;
// ── Active Power (W) ──
// meanP is in (ADC_volts)^2. Scale it exactly as Vrms and Irms are scaled:
// Vscaled = vRaw * voltageCal
// Iscaled = iRaw * (currentCalFactor / sensitivity)
// P = mean(Vscaled * Iscaled) = meanP * voltageCal * (currentCalFactor / sensitivity)
float rawActivePower = meanP * voltageCal * (currentCalFactor / sensitivity);
// Reject negative (capacitive loads read slightly negative — treat as near-unity)
if (rawActivePower < 0) rawActivePower = -rawActivePower;
// Noise floor — below 1W treat as zero
if (rawActivePower < 1.0) rawActivePower = 0;
activePower = activePower * 0.8 + rawActivePower * 0.2;
// ── Apparent Power (VA) ──
apparentPower = voltage * current;
// Safety clamp — active can never exceed apparent
if (activePower > apparentPower) activePower = apparentPower;
// ── Reactive Power (VAR) ──
reactivePower = sqrt(max(0.0f, apparentPower * apparentPower - activePower * activePower));
// ── Power Factor ──
powerFactor = (apparentPower > 1.0) ? (activePower / apparentPower) : 0;
powerFactor = constrain(powerFactor, 0.0, 1.0);
// ── Energy accumulation (Wh) — called every ~1 second ──
energyWh += activePower / 3600.0;
}
// ═══════════════════════════════════════════════════════════
// LCD DISPLAY (3 rotating pages)
// ═══════════════════════════════════════════════════════════
void updateLCD() {
lcd.clear();
switch (lcdPage) {
case 0: // Voltage & Current
lcd.setCursor(0, 0);
lcd.print("V:");
lcd.print(voltage, 1);
lcd.print("V I:");
lcd.print(current, 2);
lcd.print("A");
lcd.setCursor(0, 1);
lcd.print("P:");
lcd.print(activePower, 1);
lcd.print("W PF:");
lcd.print(powerFactor, 2);
break;
case 1: // Power breakdown
lcd.setCursor(0, 0);
lcd.print("App:");
lcd.print(apparentPower, 1);
lcd.print("VA");
lcd.setCursor(0, 1);
lcd.print("Rea:");
lcd.print(reactivePower, 1);
lcd.print("VAR");
break;
case 2: // Energy & IP
lcd.setCursor(0, 0);
lcd.print("Energy:");
lcd.print(energyWh, 2);
lcd.print("Wh");
lcd.setCursor(0, 1);
if (WiFi.status() == WL_CONNECTED) {
lcd.print(WiFi.localIP().toString());
} else {
lcd.print("WiFi offline");
}
break;
}
}
// ═══════════════════════════════════════════════════════════
// SERIAL OUTPUT
// ═══════════════════════════════════════════════════════════
void printSerial() {
Serial.print("V: "); Serial.print(voltage, 1);
Serial.print(" V | I: "); Serial.print(current, 2);
Serial.print(" A | P: "); Serial.print(activePower, 1);
Serial.print(" W | S: "); Serial.print(apparentPower, 1);
Serial.print(" VA | Q: ");Serial.print(reactivePower, 1);
Serial.print(" VAR | PF: "); Serial.print(powerFactor, 2);
Serial.print(" | E: "); Serial.print(energyWh, 3);
Serial.println(" Wh");
}
// ═══════════════════════════════════════════════════════════
// WEB SERVER — Main Dashboard
// ═══════════════════════════════════════════════════════════
void handleRoot() {
String html = R"rawhtml(
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Energy Monitor</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Rajdhani:wght@400;600;700&display=swap');
:root {
--bg: #0a0e1a;
--panel: #0f1628;
--border: #1e2d50;
--accent: #00d4ff;
--accent2: #ff6b35;
--accent3: #39ff14;
--warn: #ffcc00;
--text: #c8d8f0;
--dim: #4a6080;
--glow: 0 0 20px rgba(0,212,255,0.3);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: 'Rajdhani', sans-serif;
min-height: 100vh;
padding: 20px;
background-image:
radial-gradient(ellipse at 20% 20%, rgba(0,212,255,0.04) 0%, transparent 60%),
radial-gradient(ellipse at 80% 80%, rgba(255,107,53,0.04) 0%, transparent 60%);
}
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 24px;
background: var(--panel);
box-shadow: var(--glow);
}
header h1 {
font-size: 1.5rem;
font-weight: 700;
letter-spacing: 3px;
text-transform: uppercase;
color: var(--accent);
}
.status {
display: flex;
align-items: center;
gap: 8px;
font-family: 'Share Tech Mono', monospace;
font-size: 0.75rem;
color: var(--dim);
}
.dot {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--accent3);
box-shadow: 0 0 8px var(--accent3);
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
position: relative;
overflow: hidden;
transition: border-color 0.3s;
}
.card:hover { border-color: var(--accent); }
.card::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--card-accent, var(--accent)), transparent);
}
.card.voltage { --card-accent: var(--accent); }
.card.current { --card-accent: var(--accent2); }
.card.power { --card-accent: var(--accent3); }
.card.apparent { --card-accent: var(--warn); }
.card.reactive { --card-accent: #b44fff; }
.card.pf { --card-accent: #ff4fa3; }
.card.energy { --card-accent: var(--accent3); }
.card-label {
font-size: 0.7rem;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--dim);
margin-bottom: 8px;
}
.card-value {
font-family: 'Share Tech Mono', monospace;
font-size: 2.2rem;
font-weight: 400;
color: #fff;
line-height: 1;
}
.card-value span {
font-size: 1rem;
color: var(--dim);
margin-left: 4px;
}
.card-icon {
position: absolute;
top: 16px; right: 16px;
font-size: 1.4rem;
opacity: 0.25;
}
/* Power Factor bar */
.pf-bar-wrap {
margin-top: 12px;
height: 4px;
background: var(--border);
border-radius: 2px;
overflow: hidden;
}
.pf-bar {
height: 100%;
background: linear-gradient(90deg, var(--accent2), var(--accent3));
border-radius: 2px;
transition: width 0.8s ease;
}
/* Trend graph placeholder */
.graph-card {
grid-column: 1 / -1;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
}
.graph-card h3 {
font-size: 0.7rem;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--dim);
margin-bottom: 16px;
}
canvas#chart {
width: 100% !important;
height: 160px !important;
}
/* Reset button */
.btn-reset {
background: transparent;
border: 1px solid var(--accent2);
color: var(--accent2);
padding: 8px 20px;
border-radius: 4px;
font-family: 'Rajdhani', sans-serif;
font-size: 0.85rem;
letter-spacing: 1px;
cursor: pointer;
transition: all 0.2s;
}
.btn-reset:hover {
background: var(--accent2);
color: #000;
}
footer {
text-align: center;
color: var(--dim);
font-size: 0.7rem;
letter-spacing: 1px;
margin-top: 20px;
}
</style>
</head>
<body>
<header>
<h1>⚡ Energy Monitor</h1>
<div class="status">
<div class="dot"></div>
<span id="ts">LIVE</span>
<button class="btn-reset" onclick="resetEnergy()">RESET ENERGY</button>
</div>
</header>
<div class="grid">
<div class="card voltage">
<div class="card-icon">๐</div>
<div class="card-label">Voltage</div>
<div class="card-value" id="v">—<span>V</span></div>
</div>
<div class="card current">
<div class="card-icon">〜</div>
<div class="card-label">Current</div>
<div class="card-value" id="i">—<span>A</span></div>
</div>
<div class="card power">
<div class="card-icon">⚡</div>
<div class="card-label">Active Power</div>
<div class="card-value" id="p">—<span>W</span></div>
</div>
<div class="card apparent">
<div class="card-icon">๐</div>
<div class="card-label">Apparent Power</div>
<div class="card-value" id="s">—<span>VA</span></div>
</div>
<div class="card reactive">
<div class="card-icon">๐</div>
<div class="card-label">Reactive Power</div>
<div class="card-value" id="q">—<span>VAR</span></div>
</div>
<div class="card pf">
<div class="card-icon">๐</div>
<div class="card-label">Power Factor</div>
<div class="card-value" id="pf">—</div>
<div class="pf-bar-wrap"><div class="pf-bar" id="pf-bar" style="width:0%"></div></div>
</div>
<div class="card energy">
<div class="card-icon">๐</div>
<div class="card-label">Energy Consumed</div>
<div class="card-value" id="e">—<span>Wh</span></div>
</div>
</div>
<div class="graph-card">
<h3>Active Power — Last 60 seconds</h3>
<canvas id="chart"></canvas>
</div>
<footer>ESP32 ENERGY MONITOR | AUTO-REFRESH 1s</footer>
<script>
const maxPoints = 60;
const history = new Array(maxPoints).fill(null);
let canvas, ctx, W, H;
function initCanvas() {
canvas = document.getElementById('chart');
resize();
window.addEventListener('resize', resize);
}
function resize() {
W = canvas.offsetWidth;
H = 160;
canvas.width = W;
canvas.height = H;
}
function drawChart() {
if (!ctx) ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, W, H);
const valid = history.filter(v => v !== null);
const maxVal = valid.length ? Math.max(...valid, 10) * 1.2 : 100;
// Grid lines
ctx.strokeStyle = 'rgba(30,45,80,0.8)';
ctx.lineWidth = 1;
for (let g = 0; g <= 4; g++) {
const y = H - (g / 4) * H;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
ctx.fillStyle = 'rgba(74,96,128,0.7)';
ctx.font = '10px Share Tech Mono';
ctx.fillText(Math.round(maxVal * g / 4) + 'W', 4, y - 3);
}
// Line
const step = W / (maxPoints - 1);
ctx.beginPath();
ctx.strokeStyle = '#00d4ff';
ctx.lineWidth = 2;
ctx.shadowBlur = 8;
ctx.shadowColor = '#00d4ff';
let started = false;
for (let i = 0; i < maxPoints; i++) {
if (history[i] === null) continue;
const x = i * step;
const y = H - (history[i] / maxVal) * H;
if (!started) { ctx.moveTo(x, y); started = true; }
else ctx.lineTo(x, y);
}
ctx.stroke();
ctx.shadowBlur = 0;
// Fill
ctx.lineTo((maxPoints - 1) * step, H);
ctx.lineTo(0, H);
ctx.closePath();
const grad = ctx.createLinearGradient(0, 0, 0, H);
grad.addColorStop(0, 'rgba(0,212,255,0.15)');
grad.addColorStop(1, 'rgba(0,212,255,0)');
ctx.fillStyle = grad;
ctx.fill();
}
async function fetchData() {
try {
const r = await fetch('/data');
const d = await r.json();
document.getElementById('v').innerHTML = parseFloat(d.v).toFixed(1) + '<span>V</span>';
document.getElementById('i').innerHTML = parseFloat(d.i).toFixed(2) + '<span>A</span>';
document.getElementById('p').innerHTML = parseFloat(d.p).toFixed(1) + '<span>W</span>';
document.getElementById('s').innerHTML = parseFloat(d.s).toFixed(1) + '<span>VA</span>';
document.getElementById('q').innerHTML = parseFloat(d.q).toFixed(1) + '<span>VAR</span>';
document.getElementById('pf').innerHTML = parseFloat(d.pf).toFixed(2);
document.getElementById('e').innerHTML = parseFloat(d.e).toFixed(2) + '<span>Wh</span>';
document.getElementById('pf-bar').style.width = (parseFloat(d.pf) * 100) + '%';
document.getElementById('ts').textContent = new Date().toLocaleTimeString();
history.shift();
history.push(parseFloat(d.p));
drawChart();
} catch(e) {
console.error('Fetch error:', e);
}
}
async function resetEnergy() {
await fetch('/reset');
}
initCanvas();
fetchData();
setInterval(fetchData, 1000);
</script>
</body>
</html>
)rawhtml";
server.send(200, "text/html", html);
}
// ─────────────────────────────────────────
// /data → JSON endpoint
// ─────────────────────────────────────────
void handleData() {
String json = "{";
json += "\"v\":" + String(voltage, 1) + ",";
json += "\"i\":" + String(current, 2) + ",";
json += "\"p\":" + String(activePower, 1) + ",";
json += "\"s\":" + String(apparentPower, 1) + ",";
json += "\"q\":" + String(reactivePower, 1) + ",";
json += "\"pf\":" + String(powerFactor, 2) + ",";
json += "\"e\":" + String(energyWh, 3);
json += "}";
server.send(200, "application/json", json);
}
// ─────────────────────────────────────────
// /reset → clear energy counter
// ─────────────────────────────────────────
void handleReset() {
energyWh = 0;
server.send(200, "text/plain", "OK");
}
Comments
Post a Comment