Community project

ESP32 Energy Monitor Control

ESP32
Photo of ESP32 Energy Monitor Control
Generated with AI

Anderson 7

Last updated August 11, 2026

This project builds a real-time energy monitor and control system using an ESP32 microcontroller paired with a PZEM-004T power meter and ILI9341 touchscreen display. The system measures voltage, current, power consumption, and energy usage while providing relay-based load control and overcurrent protection via an audible alarm.

Builders will receive a complete wiring diagram, parts list, and step-by-step assembly instructions that prioritize testing the display first before integrating the power sensing and control modules. The firmware includes a web interface for remote monitoring and control, making this ideal for tracking energy consumption in workshops, labs, or home automation setups.

Wiring diagram

Wiring diagram for ESP32 Energy Monitor Control

Gather all the parts

QtyComponent
1

PZEM-004T

100 A CT

Multi-function single-phase AC power monitor module measuring voltage (80-260V AC), current (0-100A via CT clamp), active power (0-22kW), active energy (0-9999.99kWh), frequency (45-65Hz), and power factor (0.00-1.00). Communicates via TTL UART using a Modbus-RTU style protocol at 9600 baud. Uses a non-invasive split-core current transformer (CT) for safe current measurement. Logic powered at 5V DC; the AC mains supply powers the internal metering circuitry. Features 247 programmable slave addresses for multi-device Modbus buses.

1

ILI9341 TFT Touchscreen

240 x 320

240x320 SPI TFT display using the ILI9341 LCD controller with an XPT2046 resistive touch controller sharing the SPI bus

1

Buzzer

2-pin active buzzer

Piezo buzzer for sound output

1

Breadboard-friendly SPDT Slide Switch

SPDT

Compact through-hole SPDT slide switch with three pins for direct breadboard insertion.

1

Relay Module

3-channel, 30 A contacts

Single channel relay module

1

Resistor

10 kΩ

Through-hole resistor (current-limiting in series with an LED)

Assemble it in 4 steps

1. Mount the ESP32 across the two breadboards

Place the two breadboards side-by-side. Mount the ESP32 across their facing inner edges so there are accessible terminal holes beside both pin rows. Keep the USB connector reachable.

  • Use only the central terminal strips for signal jumpers; the red/blue rails are for power and ground distribution.
  • Do not connect mains wiring, PZEM AC terminals, or relay COM/NO/NC terminals to either breadboard.

2. Connect and test the TFT first

With USB disconnected, wire the TFT: VCC and LED to ESP32 3V3; GND to ESP32 GND; SDI/MOSI to GPIO23; SDO/MISO to GPIO19; SCK to GPIO18; CS to GPIO5; DC to GPIO2; and RESET to GPIO4. Leave touch and SD-card pins disconnected for the first test.

  • The TFT uses 3.3 V, not 5 V.
  • The web dashboard firmware also displays its address on the TFT after it starts.
  • Recheck VCC, LED, and GND before USB power is connected.

3. Deploy and join the ESP32 Wi-Fi network

Power the ESP32 from USB and press Schematik's Deploy button. On a phone or computer, open Wi-Fi settings and join the network named PowerMonitor-ESP32 using password monitor123. Then open a browser and go to http://192.168.4.1.

  • This is a local web app hosted directly by the ESP32. Your phone may report that the network has no internet; stay connected anyway.
  • The dashboard is read-only and refreshes measurements every two seconds.
  • Change the default Wi-Fi password in the firmware before using this project outside a private demonstration.

4. Add the remaining low-voltage modules after the display test

With USB unplugged, connect PZEM RX to GPIO17; connect PZEM TX through the 10 kΩ / 20 kΩ divider to GPIO16; connect relay IN1, IN2, IN3 to GPIO25, GPIO26, GPIO33; connect the SPDT COM to GPIO14 and one outer switch terminal to GND. Add the buzzer only with a suitable transistor driver.

  • All low-voltage modules need a common GND with the ESP32.
  • The physical SPDT switch is the relay enable control.
  • A bare two-pin buzzer must not be driven directly from an ESP32 GPIO; use an NPN transistor driver.
  • The PZEM TX divider requires both the listed 10 kΩ resistor and a physical 20 kΩ resistor from the divider junction to GND.

Review all connections

1. Connections between "tft_1" and "ESP32"

Functiontft_1ESP32
powerVCC3V3
groundGNDGND
spiMOSIGPIO 23
spiMISOGPIO 19
spiSCKGPIO 18
digitalTFT_CSGPIO 5
digitalTFT_DCGPIO 2
digitalTFT_RSTGPIO 4
digitalTOUCH_CSGPIO 27
powerTFT_BL3V3

2. Connections between "pzem_1" and "ESP32"

Functionpzem_1ESP32
powerVCC5V
groundGNDGND
digitalRXGPIO 17
digitalTXResistor P1EXT

3. Connections between "pzem_tx_resistor_top" and "ESP32"

Functionpzem_tx_resistor_topESP32
digitalP2GPIO 16

4. Connections between "relay_3ch_1" and "ESP32"

Functionrelay_3ch_1ESP32
powerVCC5V
groundGNDGND
digitalINGPIO 25
digitalIN2GPIO 26
digitalIN3GPIO 33

5. Connections between "spdt_switch_1" and "ESP32"

Functionspdt_switch_1ESP32
digitalCOMGPIO 14
groundAGND

6. Connections between "buzzer_1" and "ESP32"

Functionbuzzer_1ESP32
digitalSIGNALGPIO 32
groundGNDGND

Deploy the firmware

#include <Arduino.h>
#include <SPI.h>
#include <WiFi.h>
#include <WebServer.h>
#include <Adafruit_GFX.h>
#include <Adafruit_ILI9341.h>
#include <PZEM004Tv30.h>


// Forward declarations

// Forward declarations
void handleRelay();

void setRelays(bool on);
void statusLine(const char *s,uint16_t color);
void drawHeader();
void drawReadings();
void drawError();
const char* getStatus();
void handleStatus();

constexpr uint8_t TFT_CS=5, TFT_DC=2, TFT_RST=4, TFT_SCK=18, TFT_MISO=19, TFT_MOSI=23;
constexpr uint8_t PZEM_RX=16, PZEM_TX=17, RELAY_1=25, RELAY_2=26, RELAY_3=33, BUZZER_PIN=32, RELAY_ENABLE_SWITCH=14;
constexpr float OVERCURRENT_A=10.0f, TARIFF_PER_KWH=0.20f;
constexpr uint32_t SAMPLE_INTERVAL_MS=2000, ALARM_TOGGLE_MS=300;
const char *AP_SSID="PowerMonitor-ESP32";
const char *AP_PASSWORD="monitor123";

Adafruit_ILI9341 tft(TFT_CS,TFT_DC,TFT_RST);
PZEM004Tv30 pzem(Serial2,PZEM_RX,PZEM_TX);
WebServer server(80);
bool tripped=false,buzzerOn=false,pzemValid=false,outputsOn=false;
bool webRequestedOn=true;
float measuredVoltage=NAN, measuredCurrent=NAN, measuredPower=NAN, measuredEnergy=NAN, measuredPf=NAN;
uint32_t lastSampleMs=0,lastAlarmMs=0;

const char WEB_PAGE[] PROGMEM=R"html(
<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"><title>Power Monitor</title><style>body{margin:0;background:#09131d;color:#edf7ff;font-family:Arial}.wrap{max-width:700px;margin:auto;padding:20px}h1{color:#4de1e8;margin:0 0 6px}.sub,.label,small{color:#aac2d0}.grid{display:grid;grid-template-columns:repeat(2,1fr);gap:12px}.card,#state{background:#132635;border-radius:12px;padding:16px}.value{font-size:28px;font-weight:bold;margin-top:5px}#state{margin-top:14px;text-align:center;font-weight:bold}.ok{background:#136b45!important}.warn{background:#926114!important}.bad{background:#9c2630!important}small{display:block;margin-top:20px}</style></head><body><main class="wrap"><h1>Power Monitor</h1><div class="sub">ESP32 local dashboard</div><section class="grid"><div class="card"><div class="label">Voltage</div><div class="value" id="v">-- V</div></div><div class="card"><div class="label">Current</div><div class="value" id="i">-- A</div></div><div class="card"><div class="label">Power</div><div class="value" id="p">-- W</div></div><div class="card"><div class="label">Energy</div><div class="value" id="e">-- kWh</div></div><div class="card"><div class="label">Power factor</div><div class="value" id="f">--</div></div><div class="card"><div class="label">Estimated cost</div><div class="value" id="c">--</div></div></section><div id="state">Connecting...</div><section class="card" style="margin-top:14px"><div class="label">Voice control</div><button onclick="listenVoice()" style="margin-top:10px;padding:12px;background:#4de1e8;border:0;border-radius:8px;font-weight:bold">Speak command</button><div id="voice" class="sub" style="margin-top:8px">Say “turn outputs on” or “turn outputs off”.</div></section><small>Voice ON still requires the physical SPDT enable switch to be ON, valid PZEM readings, and no over-current trip. Voice cannot reset a trip.</small></main><script>function n(v,d,u){return Number.isFinite(v)?v.toFixed(d)+u:'--'+u}async function u(){try{let s=await (await fetch('/api/status',{cache:'no-store'})).json();v.textContent=n(s.v,1,' V');i.textContent=n(s.i,2,' A');p.textContent=n(s.p,0,' W');e.textContent=n(s.e,3,' kWh');f.textContent=n(s.f,2,'');c.textContent=Number.isFinite(s.c)?s.c.toFixed(2):'--';state.textContent=s.s;state.className=s.t?'bad':s.o?'ok':'warn'}catch(x){state.textContent='Dashboard connection lost';state.className='bad'}}async function relayCommand(state){try{let r=await fetch('/api/relay?state='+state,{cache:'no-store'});let j=await r.json();voice.textContent=j.message;u()}catch(e){voice.textContent='Command could not reach the ESP32'}}function listenVoice(){let R=window.SpeechRecognition||window.webkitSpeechRecognition;if(!R){voice.textContent='Voice recognition is unavailable here. Use Chrome or Edge.';return}let r=new R();r.lang='en-US';r.interimResults=false;r.maxAlternatives=1;voice.textContent='Listening…';r.onresult=e=>{let text=e.results[0][0].transcript.toLowerCase();voice.textContent='Heard: '+text;if(text.includes('off')||text.includes('stop'))relayCommand('off');else if(text.includes('on')||text.includes('start'))relayCommand('on');else voice.textContent='Say: turn outputs on, or turn outputs off.'};r.onerror=e=>voice.textContent='Voice error: '+e.error;r.start()}u();setInterval(u,2000)</script></body></html>
)html";

void setRelays(bool on){digitalWrite(RELAY_1,on?LOW:HIGH);digitalWrite(RELAY_2,on?LOW:HIGH);digitalWrite(RELAY_3,on?LOW:HIGH);}
void statusLine(const char *s,uint16_t color){tft.fillRect(0,280,240,40,color);tft.setTextColor(ILI9341_WHITE,color);tft.setTextSize(2);tft.setCursor(10,292);tft.print(s);}
void drawHeader(){tft.fillScreen(ILI9341_BLACK);tft.fillRect(0,0,240,30,ILI9341_DARKCYAN);tft.setTextColor(ILI9341_WHITE,ILI9341_DARKCYAN);tft.setTextSize(2);tft.setCursor(8,8);tft.print("POWER MONITOR");tft.setTextColor(ILI9341_LIGHTGREY,ILI9341_BLACK);tft.setTextSize(1);tft.setCursor(8,39);tft.print("Web: 192.168.4.1");}
void drawReadings(){tft.fillRect(0,55,240,220,ILI9341_BLACK);tft.setTextSize(2);tft.setTextColor(ILI9341_CYAN,ILI9341_BLACK);tft.setCursor(10,62);tft.print("Voltage:");tft.setTextColor(ILI9341_WHITE,ILI9341_BLACK);tft.setCursor(130,62);tft.printf("%.1f V",measuredVoltage);tft.setTextColor(ILI9341_CYAN,ILI9341_BLACK);tft.setCursor(10,97);tft.print("Current:");tft.setTextColor(ILI9341_WHITE,ILI9341_BLACK);tft.setCursor(130,97);tft.printf("%.2f A",measuredCurrent);tft.setTextColor(ILI9341_CYAN,ILI9341_BLACK);tft.setCursor(10,132);tft.print("Power:");tft.setTextColor(ILI9341_WHITE,ILI9341_BLACK);tft.setCursor(130,132);tft.printf("%.0f W",measuredPower);tft.setTextColor(ILI9341_CYAN,ILI9341_BLACK);tft.setCursor(10,167);tft.print("Energy:");tft.setTextColor(ILI9341_WHITE,ILI9341_BLACK);tft.setCursor(130,167);tft.printf("%.3f kWh",measuredEnergy);tft.setTextColor(ILI9341_CYAN,ILI9341_BLACK);tft.setCursor(10,202);tft.print("PF:");tft.setTextColor(ILI9341_WHITE,ILI9341_BLACK);tft.setCursor(130,202);tft.printf("%.2f",measuredPf);}
void drawError(){tft.fillRect(0,55,240,220,ILI9341_BLACK);tft.setTextColor(ILI9341_RED,ILI9341_BLACK);tft.setTextSize(2);tft.setCursor(15,105);tft.print("PZEM READ ERROR");}
const char* getStatus(){if(!pzemValid)return "PZEM read error - outputs off";if(tripped)return "Tripped - overcurrent";if(!webRequestedOn)return "Web command off - outputs off";if(!outputsOn)return "Physical switch off - outputs off";return "Normal - outputs on";}
void handleStatus(){String j="{\"v\":"+String(measuredVoltage,2)+",\"i\":"+String(measuredCurrent,3)+",\"p\":"+String(measuredPower,1)+",\"e\":"+String(measuredEnergy,3)+",\"f\":"+String(measuredPf,2)+",\"c\":"+String(measuredEnergy*TARIFF_PER_KWH,2)+",\"o\":"+(outputsOn?"true":"false")+",\"t\":"+(tripped?"true":"false")+",\"s\":\""+getStatus()+"\"}";server.send(200,"application/json",j);}
void handleRelay(){String state=server.arg("state");if(state=="off"){webRequestedOn=false;setRelays(false);outputsOn=false;server.send(200,"application/json","{\"message\":\"Outputs requested OFF\"}");return;}if(state=="on"){webRequestedOn=true;if(tripped){server.send(423,"application/json","{\"message\":\"Trip is active; power-cycle ESP32 to reset\"}");return;}if(!pzemValid){server.send(423,"application/json","{\"message\":\"PZEM reading invalid; outputs remain off\"}");return;}if(digitalRead(RELAY_ENABLE_SWITCH)!=LOW){server.send(423,"application/json","{\"message\":\"Turn physical enable switch ON first\"}");return;}server.send(200,"application/json","{\"message\":\"Outputs requested ON\"}");return;}server.send(400,"application/json","{\"message\":\"Say on or off\"}");}
void setup(){
  // Configure outputs first. Relay modules are active-low, so drive HIGH immediately
  // afterwards to hold all relays de-energized at startup.
  pinMode(RELAY_1, OUTPUT);
  pinMode(RELAY_2, OUTPUT);
  pinMode(RELAY_3, OUTPUT);
  pinMode(BUZZER_PIN, OUTPUT);
  pinMode(RELAY_ENABLE_SWITCH, INPUT_PULLUP);
  setRelays(false);
  digitalWrite(BUZZER_PIN, LOW);
  Serial.begin(115200);Serial2.begin(9600,SERIAL_8N1,PZEM_RX,PZEM_TX);SPI.begin(TFT_SCK,TFT_MISO,TFT_MOSI,TFT_CS);tft.begin();tft.setRotation(1);drawHeader();statusLine("STARTING - OUTPUTS OFF",ILI9341_ORANGE);WiFi.mode(WIFI_AP);WiFi.softAP(AP_SSID,AP_PASSWORD);server.on("/",HTTP_GET,[](){server.send_P(200,"text/html",WEB_PAGE);});server.on("/api/status",HTTP_GET,handleStatus);server.on("/api/relay",HTTP_GET,handleRelay);server.begin();}
void loop(){server.handleClient();uint32_t now=millis();if(tripped&&now-lastAlarmMs>=ALARM_TOGGLE_MS){lastAlarmMs=now;buzzerOn=!buzzerOn;digitalWrite(BUZZER_PIN,buzzerOn?HIGH:LOW);}if(now-lastSampleMs<SAMPLE_INTERVAL_MS)return;lastSampleMs=now;measuredVoltage=pzem.voltage();measuredCurrent=pzem.current();measuredPower=pzem.power();measuredEnergy=pzem.energy();measuredPf=pzem.pf();pzemValid=!(isnan(measuredVoltage)||isnan(measuredCurrent)||isnan(measuredPower)||isnan(measuredEnergy)||isnan(measuredPf));if(!pzemValid){outputsOn=false;setRelays(false);digitalWrite(BUZZER_PIN,LOW);drawError();statusLine("FAULT - OUTPUTS OFF",ILI9341_RED);return;}drawReadings();bool enabled=digitalRead(RELAY_ENABLE_SWITCH)==LOW;if(!tripped&&measuredCurrent>=OVERCURRENT_A){tripped=true;outputsOn=false;setRelays(false);statusLine("TRIPPED - OVERCURRENT",ILI9341_RED);}else if(tripped){outputsOn=false;setRelays(false);statusLine("TRIPPED - POWER CYCLE TO RESET",ILI9341_RED);}else if(!enabled){outputsOn=false;setRelays(false);statusLine("SWITCH OFF - OUTPUTS OFF",ILI9341_ORANGE);}else if(!webRequestedOn){outputsOn=false;setRelays(false);statusLine("WEB OFF - OUTPUTS OFF",ILI9341_ORANGE);}else{outputsOn=true;setRelays(true);statusLine("NORMAL - OUTPUTS ON",ILI9341_DARKGREEN);}}

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik