Build an IoT AC Energy Meter with PZEM-004T & ESP32 Web Server

Monitor real-time voltage, current, power, energy (kWh), frequency, and power factor from any browser — no LCD required.

Ever wondered exactly how many watts your air conditioner pulls at 2 PM versus midnight? Or how much your space heater is actually adding to your electricity bill every month? A $12 module and an ESP32 can answer those questions — live, from your phone — without calling an electrician.

In this guide you’ll build a fully functional IoT-based AC energy meter using the PZEM-004T v3.0 and an ESP32 microcontroller. The ESP32 hosts a local web server that streams voltage, current, active power, cumulative energy (kWh), grid frequency, and power factor to any browser on your Wi-Fi network, refreshing every two seconds without a page reload. By the end, you’ll have working Arduino code, a clean wiring diagram explanation, and a dashboard you can check from the couch.


Project Overview

Traditional energy meters give you one number at the end of the month. Smart energy meters give you a continuous stream of data you can act on. This project bridges the gap between the two — using hardware that is readily available on Amazon for under $25 total and firmware you can flash in under ten minutes.

The PZEM-004T v3.0 is a factory-calibrated, industrial-grade AC metering module built around Peacefair’s SD3004 metering IC. It communicates with the ESP32 over UART using the Modbus-RTU protocol, delivering six electrical parameters with ±0.5% accuracy across the board. The ESP32 receives that data, stores it in global variables, and serves it both as an initial HTML dashboard and as a live JSON endpoint. A small JavaScript snippet on the page polls that /data endpoint every two seconds using XMLHttpRequest — no page reload, no flickering, just smooth real-time updates.

Unlike older DIY builds that pair a ZMPT101B voltage sensor with an SCT-013 current transformer, this setup requires no calibration, no burden resistor calculations, and no dealing with analog noise. The PZEM-004T comes pre-calibrated from the factory. You wire it up, upload the code, and it works.

What you’ll be able to measure:

  • Voltage — 80 to 260 V AC, useful for both 120 V (US standard) and 230 V systems
  • Current — 0 to 100 A via the external clamp-on CT sensor
  • Active Power — 0 to 23 kW, displayed in watts
  • Energy — cumulative consumption in kWh, resettable via software
  • Frequency — 45 to 65 Hz, letting you track grid stability
  • Power Factor — 0.00 to 1.00, a critical efficiency metric for inductive loads like motors and compressors

How It Works — The Working Principle

Understanding the measurement chain makes troubleshooting much easier, so let’s walk through it step by step.

AC Voltage Measurement

The PZEM-004T connects directly to your AC mains via its screw terminal block (L and N). Internally, the module uses a precision resistor divider network to step the mains voltage down to a safe sampling level. The SD3004 metering IC samples this divided voltage waveform and calculates the true RMS value in hardware.

AC Current Measurement (The CT Sensor)

Current is measured non-invasively using a Current Transformer (CT). The CT sensor ships with the module and clamps around the live wire only — you never break the circuit. When alternating current flows through the wire, it generates a changing magnetic field. The CT’s secondary winding picks up this field and produces a proportionally smaller secondary current. The PZEM-004T’s internal circuitry scales this down further and feeds it to the metering IC, which calculates the true RMS current.

Critical rule: Clamp the CT around the live (hot) wire only — never around both wires simultaneously. Clamping both cancels the magnetic fields and gives you a zero reading.

Power, Energy, and Power Factor Calculation

Once the module has true RMS voltage (V) and true RMS current (A), it calculates:

  • Active Power (W) = V × I × cos(φ), where φ is the phase angle between voltage and current waveforms
  • Power Factor = cos(φ), ranging from 1.0 (purely resistive load like a heater) to near 0 (highly inductive load)
  • Energy (kWh) = cumulative integration of power over time, stored in EEPROM on the module so it survives power cuts

UART Communication to ESP32

The PZEM-004T speaks Modbus-RTU over its TTL UART interface at 9600 baud (8N1 framing). The ESP32 acts as the Modbus master, sending a read-input-registers request (function code 0x04) to the module’s default slave address (0x01). The module responds with a 25-byte packet containing all six parameters. The PZEM004Tv30 Arduino library handles all the framing, CRC checking, and register parsing automatically — you just call pzem.voltage()pzem.current(), and so on.

ESP32 Web Server

The ESP32 runs two tasks concurrently (made possible by FreeRTOS running under the hood):

  1. Sensor polling loop — reads the PZEM every 2 seconds and updates global float variables
  2. Async web server — handles HTTP requests without blocking the sensor loop

When your browser first loads the dashboard (GET /), the ESP32 sends a full HTML page with the current readings embedded. Every 2 seconds thereafter, JavaScript calls GET /data, which returns a compact JSON object like:

json
{"voltage":119.8,"current":1.24,"power":144.3,"energy":0.18,"frequency":60.0,"pf":0.97}

The JavaScript parses this and updates the DOM without a full page reload — a pattern known as AJAX (Asynchronous JavaScript and XML, though here it’s JSON).

Related Project: Measure AC current by interfacing ACS712 sensor with ESP32


Required Components

All components below are widely available on Amazon.com and from electronics suppliers like Adafruit, SparkFun, and DigiKey. Prices are approximate as of mid-2025.

Component Specification
ESP32 Development Board 38-pin, dual-core, Wi-Fi + BT
PZEM-004T v3.0 with CT 100 A open CT clamp, 80–260 V
USB-A to Micro-USB cable Data-capable (not charge-only)
Breadboard (830-tie) Full-size, for prototyping
Jumper wires (M-M) 40-piece assortment
AC power strip or outlet cord For connecting the PZEM to mains
5 V USB power adapter 1 A minimum, for ESP32
Electrical tape Insulation for exposed AC terminals

Total estimated cost: $18–$28, depending on sourcing. If you already have an ESP32 and basic supplies, the PZEM-004T module alone costs around $10–$12 shipped.

Safety note: This project involves 120 or 230 V AC mains voltage. Always work with power disconnected when making or changing any AC-side connections. If you’re not comfortable working with mains wiring, ask a qualified electrician to handle the AC connections, then handle the low-voltage DC side yourself.


Sensors and Modules Explained

PZEM-004T v3.0 — The Energy Measurement Module

The PZEM-004T v3.0 is manufactured by Peacefair and has become the de-facto standard for DIY AC energy monitoring. The v3.0 designation matters: it added frequency and power factor measurements that were absent in the earlier v1.0/v2.0 variants, and it improved communication reliability.

PZEM-004T Module

Physical specs:

  • Board dimensions: 78 × 37 × 19 mm (about 3.1 × 1.5 × 0.75 in)
  • Operating temperature: –20°C to +70°C
  • Self-powered from AC mains — no external 5 V needed for measurement (5 V is needed only for the UART communication side)

Measurement specifications:

Parameter Range Resolution Accuracy
Voltage 80–260 V AC 0.1 V ±0.5%
Current 0–100 A 0.001 A ±0.5%
Active Power 0–23 kW 0.1 W ±0.5%
Energy 0–9999.99 kWh 1 Wh ±0.5%
Frequency 45–65 Hz 0.1 Hz ±0.5%
Power Factor 0.00–1.00 0.01 ±1%

On-board indicator LEDs:

  • Power LED — solid when the module is powered from AC mains
  • Pulse LED — blinks at a rate proportional to active power; faster blink = higher consumption
  • TX LED — blinks when the module is sending data
  • RX LED — blinks when the module receives a request

The CT sensor (Current Transformer): The 100 A clamp-on CT that ships with the PZEM-004T is a Class 1.5 accuracy transformer. The open-core (split-core) version lets you clamp it around an existing wire without disconnecting anything — ideal for retrofitting. The closed-core version is more accurate and mechanically stable, but requires you to thread the wire through the core during installation.

Why PZEM-004T beats the DIY alternative: A common budget alternative is pairing a ZMPT101B voltage sensor with an ACS712 or SCT-013 current sensor. That approach requires analog calibration, offset correction, burden resistor calculations, and still typically achieves only ±2–5% accuracy. The PZEM-004T delivers ±0.5% accuracy out of the box with zero calibration required.

ESP32 Development Board

The ESP32 is a dual-core 32-bit microcontroller from Espressif Systems, clocked at up to 240 MHz. For this project its key strengths are:

  • Built-in Wi-Fi (802.11 b/g/n) — hosts the web server without any external shield
  • Three hardware UART ports — UART0 is reserved for USB serial; UART2 (GPIO 16/17) is used for PZEM communication, keeping things clean
  • 3.3 V logic — compatible with the PZEM’s TTL serial interface (the PZEM UART side runs at 5 V nominally, but 3.3 V logic from the ESP32 is accepted reliably in practice)
  • FreeRTOS — allows the sensor polling and web server to run concurrently without one blocking the other

The 38-pin variant (also sold as “NodeMCU-32S” or “DOIT ESP32 DevKit V1”) is the most common and is what this guide targets. Pinout varies slightly between manufacturers, but GPIO 16 and 17 are consistently available as UART2 RX/TX.


Circuit Connections

Before touching any wires, unplug everything from AC power.

IoT AC Energy Meter with PZEM-004T & ESP32 Web Server circuit diagram

Low-Voltage Side (ESP32 ↔ PZEM-004T Communication Pins)

The PZEM-004T has two sets of terminals. The right-side connector (4-pin JST or screw block, depending on your version) handles UART communication:

PZEM-004T Pin Connect To ESP32 Pin
5V 5V (VIN) ESP32 VIN (USB 5V)
GND GND ESP32 GND
TX GPIO 16 (RX2)
RX GPIO 17 (TX2)

Note the crossover: PZEM TX goes to ESP32 RX, and PZEM RX goes to ESP32 TX. This is standard UART wiring that trips up many beginners — and is exactly what the preliminary testing in published research confirmed can prevent the sensor from responding if reversed.

High-Voltage Side (AC Mains — Work With Power Off)

The left-side terminals on the PZEM-004T connect to AC mains:

  • L terminal → Live (hot) wire of your AC circuit
  • N terminal → Neutral wire of your AC circuit
  • CT+ and CT- → The two leads from the current transformer clamp

The load you want to monitor connects between the CT sensor’s clamp position and whatever appliance is downstream. A practical approach for testing: cut one end off a cheap extension cord, connect its live and neutral to the PZEM’s L/N terminals, then clamp the CT around the live wire before reassembling the cord. Plug the load into the other end of the extension cord.

CT placement reminder: Clamp the CT around the live wire only. The neutral wire must not pass through the CT core. If both wires pass through, their magnetic fields cancel and current reads zero.

Power Supply

The ESP32 can be powered from its USB port (connected to your laptop or a phone charger) during development. The PZEM-004T powers its measurement side from the AC mains automatically — but it needs 5 V on its communication side from the ESP32’s VIN pin.


Build a Smart IoT Energy Meter with PCBWay—Custom PCBs & 3D-Printed Enclosures for Your PZEM-004T & ESP32 Project

Monitor your home or workplace energy consumption in real time with a professional IoT energy meter. PCBWay’s integrated manufacturing services help you transform your PZEM-004T power sensor and ESP32 web server prototype into a polished, ready-to-deploy monitoring device.

Custom PCB Manufacturing for Precision Power Monitoring:
PCBWay manufactures high-quality custom PCBs that consolidate your ESP32, PZEM-004T module, relay driver, and power regulation onto a single compact board. Their PCBs feature clean UART traces for stable serial communication (9600 baud), proper isolation for AC voltage sensing (80–260V), and robust power planes to handle the 5V requirements of the PZEM module. Choose their professional SMT assembly to receive a fully populated, tested board ready for web server deployment.

Electrical-Safe 3D-Printed Enclosures:
Protect your AC monitoring electronics with a custom enclosure from PCBWay’s advanced 3D printing service. Design a flame-retardant housing using heat-resistant ABS or PETG, featuring CT pass-through slots, cable glands for AC wiring, and a transparent window for status LEDs. Add internal PCB bosses, ventilation channels, and secure mounting points—all printed to precision.

Why PCBWay Delivers Professional Results:

  • Accurate Readings: Clean PCB design preserves PZEM sensor data for reliable voltage, current, power, and energy metrics
  • Electrical Safety: Custom enclosures protect users from high-voltage AC connections
  • Seamless Integration: Perfect PCB-to-enclosure fit from one trusted source

Build Your Energy Monitor Today:
Upload your PCB design and enclosure model for instant quotes, DFM feedback, and professional manufacturing only at PCBWAY.COM


Program Code

Required Libraries

Install the following libraries in your Arduino IDE before compiling:

  1. PZEM004Tv30 by Jakub Mandula — search “PZEM004Tv30” in Library Manager, or install from github.com/mandulaj/PZEM-004T-v30
  2. ESPAsyncWebServer — download ZIP from github.com/me-no-dev/ESPAsyncWebServer and install via Sketch → Include Library → Add .ZIP Library
  3. AsyncTCP — required dependency for ESPAsyncWebServer, install from github.com/me-no-dev/AsyncTCP

Also ensure you have the ESP32 board package installed: go to File → Preferences and add https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json to Additional Boards Manager URLs, then install “esp32 by Espressif Systems” from Boards Manager.

Full Arduino Sketch

/*
 * IoT AC Energy Meter — PZEM-004T v3.0 + ESP32 Web Server
 * 
 * Measures: Voltage, Current, Active Power, Energy (kWh),
 *           Frequency, Power Factor
 * Serves real-time data on a local web dashboard via Wi-Fi
 */

#include <WiFi.h>
#include <ESPAsyncWebServer.h>
#include <PZEM004Tv30.h>

// ─── Wi-Fi Credentials ────────────────────────────────────────────────────────
const char* ssid     = "YOUR_WIFI_SSID";      // Replace with your Wi-Fi name
const char* password = "YOUR_WIFI_PASSWORD";  // Replace with your Wi-Fi password

// ─── PZEM-004T on UART2 ───────────────────────────────────────────────────────
// ESP32 UART2: RX = GPIO16, TX = GPIO17
// PZEM TX → ESP32 GPIO16 | PZEM RX ← ESP32 GPIO17
HardwareSerial pzemSerial(2);
PZEM004Tv30 pzem(pzemSerial, 16, 17);

// ─── Async Web Server on Port 80 ──────────────────────────────────────────────
AsyncWebServer server(80);

// ─── Global Sensor Variables (updated every 2 seconds) ───────────────────────
float voltage    = 0.0;
float current    = 0.0;
float power      = 0.0;
float energy     = 0.0;
float frequency  = 0.0;
float pf         = 0.0;

unsigned long lastUpdate = 0;
const unsigned long UPDATE_INTERVAL = 2000; // milliseconds

// ─── HTML Dashboard ───────────────────────────────────────────────────────────
const char dashboard_html[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>IoT AC Energy Meter</title>
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body {
      font-family: 'Segoe UI', Arial, sans-serif;
      background: #0f172a;
      color: #e2e8f0;
      min-height: 100vh;
      display: flex;
      flex-direction: column;
      align-items: center;
      padding: 24px 16px;
    }
    h1 {
      font-size: 1.6rem;
      font-weight: 700;
      margin-bottom: 8px;
      color: #38bdf8;
      text-align: center;
    }
    .subtitle {
      font-size: 0.85rem;
      color: #94a3b8;
      margin-bottom: 28px;
      text-align: center;
    }
    .grid {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
      gap: 16px;
      width: 100%;
      max-width: 720px;
    }
    .card {
      background: #1e293b;
      border-radius: 12px;
      padding: 20px 16px;
      text-align: center;
      border: 1px solid #334155;
      transition: transform 0.2s;
    }
    .card:hover { transform: translateY(-2px); }
    .card-label {
      font-size: 0.75rem;
      text-transform: uppercase;
      letter-spacing: 0.08em;
      color: #64748b;
      margin-bottom: 10px;
    }
    .card-value {
      font-size: 1.9rem;
      font-weight: 700;
      line-height: 1;
    }
    .card-unit {
      font-size: 0.85rem;
      color: #94a3b8;
      margin-top: 6px;
    }
    .voltage  .card-value { color: #facc15; }
    .current  .card-value { color: #34d399; }
    .power    .card-value { color: #f87171; }
    .energy   .card-value { color: #a78bfa; }
    .freq     .card-value { color: #38bdf8; }
    .pf       .card-value { color: #fb923c; }
    .footer {
      margin-top: 28px;
      font-size: 0.75rem;
      color: #475569;
      text-align: center;
    }
    #status {
      width: 8px; height: 8px;
      border-radius: 50%;
      background: #22c55e;
      display: inline-block;
      margin-right: 6px;
      animation: pulse 2s infinite;
    }
    @keyframes pulse {
      0%, 100% { opacity: 1; }
      50%       { opacity: 0.3; }
    }
  </style>
</head>
<body>
  <h1>IoT AC Energy Meter</h1>
  <p class="subtitle"><span id="status"></span>Live data · refreshes every 2 s</p>

  <div class="grid">
    <div class="card voltage">
      <div class="card-label">Voltage</div>
      <div class="card-value" id="voltage">--</div>
      <div class="card-unit">Volts (V)</div>
    </div>
    <div class="card current">
      <div class="card-label">Current</div>
      <div class="card-value" id="current">--</div>
      <div class="card-unit">Amperes (A)</div>
    </div>
    <div class="card power">
      <div class="card-label">Active Power</div>
      <div class="card-value" id="power">--</div>
      <div class="card-unit">Watts (W)</div>
    </div>
    <div class="card energy">
      <div class="card-label">Energy</div>
      <div class="card-value" id="energy">--</div>
      <div class="card-unit">Kilowatt-hours (kWh)</div>
    </div>
    <div class="card freq">
      <div class="card-label">Frequency</div>
      <div class="card-value" id="frequency">--</div>
      <div class="card-unit">Hertz (Hz)</div>
    </div>
    <div class="card pf">
      <div class="card-label">Power Factor</div>
      <div class="card-value" id="pf">--</div>
      <div class="card-unit">Ratio (0–1)</div>
    </div>
  </div>

  <p class="footer">ESP32 + PZEM-004T v3.0 &nbsp;|&nbsp; Modbus-RTU over UART2</p>

  <script>
    function fetchData() {
      var xhr = new XMLHttpRequest();
      xhr.onreadystatechange = function () {
        if (this.readyState === 4 && this.status === 200) {
          var d = JSON.parse(this.responseText);
          document.getElementById('voltage').innerText   = d.voltage   !== null ? d.voltage.toFixed(1)   : 'Err';
          document.getElementById('current').innerText   = d.current   !== null ? d.current.toFixed(3)   : 'Err';
          document.getElementById('power').innerText     = d.power     !== null ? d.power.toFixed(1)     : 'Err';
          document.getElementById('energy').innerText    = d.energy    !== null ? d.energy.toFixed(3)    : 'Err';
          document.getElementById('frequency').innerText = d.frequency !== null ? d.frequency.toFixed(1) : 'Err';
          document.getElementById('pf').innerText        = d.pf        !== null ? d.pf.toFixed(2)        : 'Err';
        }
      };
      xhr.open('GET', '/data', true);
      xhr.send();
    }
    // Fetch immediately, then every 2 seconds
    fetchData();
    setInterval(fetchData, 2000);
  </script>
</body>
</html>
)rawliteral";

// ─── Helper: build JSON string from current readings ─────────────────────────
String buildJSON() {
  String json = "{";
  json += "\"voltage\":"   + (isnan(voltage)   ? String("null") : String(voltage,   1)) + ",";
  json += "\"current\":"   + (isnan(current)   ? String("null") : String(current,   3)) + ",";
  json += "\"power\":"     + (isnan(power)     ? String("null") : String(power,     1)) + ",";
  json += "\"energy\":"    + (isnan(energy)    ? String("null") : String(energy,    3)) + ",";
  json += "\"frequency\":" + (isnan(frequency) ? String("null") : String(frequency, 1)) + ",";
  json += "\"pf\":"        + (isnan(pf)        ? String("null") : String(pf,        2));
  json += "}";
  return json;
}

// ─── Setup ────────────────────────────────────────────────────────────────────
void setup() {
  Serial.begin(115200);
  delay(1000);

  // Initialize UART2 for PZEM-004T (9600 baud, 8N1)
  pzemSerial.begin(9600, SERIAL_8N1, 16, 17);
  Serial.println("PZEM-004T initialized on UART2 (GPIO16/17)");

  // Connect to Wi-Fi
  Serial.print("Connecting to Wi-Fi");
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println();
  Serial.print("Connected! IP Address: ");
  Serial.println(WiFi.localIP());
  Serial.println("Open this IP in your browser to view the dashboard.");

  // Serve HTML dashboard at root URL
  server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) {
    request->send_P(200, "text/html", dashboard_html);
  });

  // Serve live JSON data at /data
  server.on("/data", HTTP_GET, [](AsyncWebServerRequest *request) {
    request->send(200, "application/json", buildJSON());
  });

  server.begin();
  Serial.println("Async web server started.");
}

// ─── Main Loop ────────────────────────────────────────────────────────────────
void loop() {
  unsigned long now = millis();

  // Update sensor readings every UPDATE_INTERVAL milliseconds
  if (now - lastUpdate >= UPDATE_INTERVAL) {
    lastUpdate = now;

    voltage   = pzem.voltage();
    current   = pzem.current();
    power     = pzem.power();
    energy    = pzem.energy();
    frequency = pzem.frequency();
    pf        = pzem.pf();

    // Print to Serial Monitor for debugging
    if (isnan(voltage)) {
      Serial.println("[WARN] PZEM not responding — check wiring and AC power.");
    } else {
      Serial.printf("V: %.1f V | I: %.3f A | P: %.1f W | E: %.3f kWh | F: %.1f Hz | PF: %.2f\n",
                    voltage, current, power, energy, frequency, pf);
    }
  }
  // The async web server handles requests in the background automatically
}

Output on Webserver:

IoT AC Energy Meter with PZEM-004T & ESP32 Web Server output


Code Explanation

Let’s walk through each meaningful block so you understand what the code is doing and why — and can modify it confidently.

Library Imports

#include <WiFi.h>
#include <ESPAsyncWebServer.h>
#include <PZEM004Tv30.h>

WiFi.h is part of the Espressif Arduino core and handles all Wi-Fi connection management. ESPAsyncWebServer.h provides the non-blocking HTTP server — “asynchronous” means the server handles incoming browser requests in interrupt-driven callbacks rather than blocking loop(). This is critical because if the server blocked, sensor reads would stall. PZEM004Tv30.h provides the high-level API (pzem.voltage(), etc.) that wraps the Modbus-RTU communication protocol.

PZEM Object Initialization

HardwareSerial pzemSerial(2);
PZEM004Tv30 pzem(pzemSerial, 16, 17);

HardwareSerial(2) creates an instance of the ESP32’s third hardware UART (UART0 is Serial, UART1 is used internally, UART2 is free for us). We pass GPIO 16 as RX and GPIO 17 as TX. The PZEM004Tv30 object takes this serial port and the pin numbers, configuring the PZEM’s communication parameters automatically.

Global Float Variables

float voltage = 0.0;
float current = 0.0;
// ... etc.

These are declared globally so both loop() (which writes them) and the web server’s /data callback (which reads them) can access the same data. On the ESP32, simple float reads and writes are atomic on the main core, so no mutex is needed for this use case.

The HTML Dashboard (Stored in Flash)

const char dashboard_html[] PROGMEM = R"rawliteral( ... )rawliteral";

PROGMEM stores the HTML string in flash memory instead of RAM. The ESP32 has 4 MB of flash but only 320 KB of SRAM — storing large HTML strings in RAM would quickly exhaust it. The R"rawliteral()" syntax is a C++ raw string literal that allows the HTML to contain quotes and special characters without escaping.

The JSON Builder

String buildJSON() {
 String json = "{";
 json += "\"voltage\":" + (isnan(voltage) ? String("null") : String(voltage, 1)) + ",";
 // ...
}

isnan() checks whether a value is “not a number” — which is what the PZEM library returns when communication fails. Returning null in the JSON instead of a garbage float lets the JavaScript display “Err” gracefully rather than a nonsensical value like -1.#IND. The second argument to String() (e.g., 1 for voltage) controls decimal places.

setup() — Initialization

pzemSerial.begin(9600, SERIAL_8N1, 16, 17);

This explicitly starts UART2 at 9600 baud with 8 data bits, no parity, and 1 stop bit — exactly what the PZEM-004T expects per its Modbus configuration. Calling begin() explicitly here (before the PZEM library uses it) ensures correct pin assignment even on ESP32 boards where default UART2 pins vary.

WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); }

Blocking wait for Wi-Fi connection during setup. Once connected, WiFi.localIP() gives you the IP address to type into your browser.

Web Server Route Handlers

server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) {
 request->send_P(200, "text/html", dashboard_html);
});

Lambda function registered as the GET handler for /send_P() reads the PROGMEM string and sends it as an HTTP 200 response with content type text/html.

server.on("/data", HTTP_GET, [](AsyncWebServerRequest *request) {
 request->send(200, "application/json", buildJSON());
});

The /data endpoint that JavaScript polls every 2 seconds. Returns the current sensor values as JSON. Because ESPAsyncWebServer is non-blocking, this can respond even while loop() is in the middle of a sensor read.

loop() — The Sensor Polling Loop

if (now - lastUpdate >= UPDATE_INTERVAL) {
 lastUpdate = now;
 voltage = pzem.voltage();
 // ...
}

The millis-based non-blocking delay pattern — a staple of good Arduino code. Unlike delay(2000), this approach never blocks the CPU, keeping the async web server responsive between reads. Each pzem.xxx() call sends a Modbus request over UART2 and waits for the response (blocking for ~120 ms per call according to benchmarks), so six sequential reads take about 720 ms total — well within the 2-second window.

JavaScript AJAX in the Dashboard

function fetchData() {
 var xhr = new XMLHttpRequest();
 xhr.onreadystatechange = function () {
 if (this.readyState === 4 && this.status === 200) {
 var d = JSON.parse(this.responseText);
 document.getElementById('voltage').innerText = d.voltage !== null ? d.voltage.toFixed(1) : 'Err';
 // ...
 }
 };
 xhr.open('GET', '/data', true);
 xhr.send();
}
setInterval(fetchData, 2000);

Classic XMLHttpRequest polling. Every 2 seconds the browser silently fetches /data, parses the JSON, and updates only the six value elements in the DOM. The page never reloads, so the browser history stays clean and the user experience feels like a live app rather than a webpage.


Troubleshooting Connection Issues

Problem: PZEM address flipping between 0x00 and 0x07
Solution: Power PZEM with 3.3V instead of 5V, and ensure capacitors are installed correctly

Problem: No communication between ESP32 and PZEM
Solution: Verify you’re using GPIO 16/17 (Serial2), not other serial pins. Check TX/RX cross-connection (PZEM RX to ESP32 TX, etc.)

Problem: Inconsistent readings or noise
Solution: Add/verify capacitors, ensure secure connections (especially on breadboard), check for ground loops

Problem: ESP32 resets when PZEM is connected
Solution: Power ESP32 from stable 5V/2A supply, add bulk capacitor (470µF) to ESP32 power input


Real-World Applications

The core skill you’ve built — reading calibrated AC electrical parameters and serving them over Wi-Fi — opens up a surprisingly wide range of practical applications.

Home Energy Monitoring Dashboard

Install one PZEM-004T on your main panel’s feed and track whole-house consumption. The cumulative kWh counter lets you calculate your daily and monthly electricity cost in real time. Wire the live dashboard to a simple cost-per-kWh formula and you have a DIY smart meter that rivals commercial options costing $80–$150.

Appliance-Level Energy Profiling

Curious how much your window AC unit really costs to run per hour? Install a PZEM on a dedicated circuit or use the clamp CT on a single appliance’s cord and log the data over 24 hours. The power factor reading is especially useful here: an old AC compressor or refrigerator with a degraded start capacitor will show a noticeably lower power factor than a healthy unit.

Smart Plug / Load Controller Integration

Combine this energy meter with a relay module (solid-state or mechanical) wired to the ESP32’s GPIO. Add a power threshold check in the firmware: if power > threshold, cut the relay. You now have an over-power protection system — useful for protecting motors, waterheaters, or shared circuits from overload.

Solar and Battery System Monitoring

Pair the PZEM-004T on your solar inverter’s AC output to track how much energy your panels are generating versus how much your home is consuming. The bidirectional frequency measurement also helps detect grid synchronization issues in grid-tie systems.

Industrial and Lab Bench Monitoring

The ±0.5% accuracy, 45–65 Hz frequency range, and 0–100 A current range make this setup credible for benchtop power analysis, motor characterization, and small-scale industrial load profiling. Add SD card logging (ESP32 has SPI for that) to capture consumption trends over time.

Educational and STEM Projects

This is an excellent platform for teaching electrical engineering concepts. Students can measure the power factor of different load types (resistive heater vs. inductive motor vs. capacitive power supply), observe frequency stability, and learn about Modbus-RTU communication — all with a $10 module and code that fits in a single file.

Integration with Home Automation Platforms

The JSON endpoint at /data is easily polled by platforms like Home Assistant, Node-RED, InfluxDB + Grafana, and Homebridge. Add a few lines to push the data via MQTT and your energy readings become just another data stream in your home automation ecosystem.


9. Conclusion

Building a fully functional IoT AC energy meter is now a weekend afternoon project rather than a multi-week engineering task. The PZEM-004T v3.0 handles all the hard measurement work — true RMS calculation, Modbus framing, EEPROM energy accumulation — at factory precision. The ESP32’s dual-core architecture keeps the sensor polling loop and the async web server running concurrently without conflicts. And the AJAX-based dashboard delivers a genuinely smooth, real-time monitoring experience from any browser on your local network.

The total hardware cost lands between $18 and $28. The code compiles and uploads in minutes. The accuracy at ±0.5% is good enough for real energy auditing work, not just hobby curiosity.

From here, the natural next steps are:

  • Add MQTT to push readings to Home Assistant or any broker
  • Add SD card logging to capture historical data for trend analysis
  • Add a second PZEM-004T on a different circuit (each gets a unique Modbus address via software, and they share the same UART bus)
  • Add a relay for automatic over-power cutoff or scheduled load control
  • Add HTTPS (ESP32 supports TLS) if you expose the server beyond your local network

If you hit a “PZEM not responding” message in the serial monitor, check these in order: (1) RX/TX crossed correctly, (2) PZEM has AC power connected and the power LED is on, (3) the UART2 baud rate is 9600, (4) GND is shared between ESP32 and PZEM. Those four checks resolve the overwhelming majority of communication failures.

Energy awareness is the first step to energy efficiency — and now you have the tools to measure both.


Have questions or built something cool with this setup? Drop a comment below — circuitschools.com is always happy to troubleshoot or hear about modifications.

Add a Comment

Leave a Reply

Your email address will not be published. Required fields are marked *