Contents — 26 sections
Project Overview
A mains socket that measures true power — not the fiction you get from multiplying RMS voltage by RMS current — logs kilowatt-hours per appliance, and switches the load on a schedule or from your phone.
Most "energy monitor" projects measure current with an ACS712 or a clamp, assume the mains is a clean 230 V sine, multiply, and report a number. For a resistive load such as a heater that number is roughly right. For anything with a switch-mode supply — a laptop charger, an LED bulb, a fridge — it can be wrong by 40 % or more, because the current is not in phase with the voltage and is not sinusoidal.
This build uses a PZEM-004T v3, which samples voltage and current simultaneously and integrates their product over each cycle. That is the definition of real power, and it is the only measurement that corresponds to what your electricity meter bills you for. The module also reports apparent power, power factor and frequency, so you can see exactly how badly the naive calculation would have failed for each appliance.
The safety architecture matters more here than in any other project in this catalogue, because the measurement side sits at mains potential. The PZEM's UART is opto-isolated, and that isolation boundary is the only thing between 230 V and your ESP32 — and, through the USB cable, your laptop. Respect it: never bridge the grounds, never probe the mains side with an oscilloscope that is earthed, and never work on this powered.
The result is per-appliance data you can actually act on. Standby draw is usually the surprise: a television that reads 0.4 W on paper often measures 8 W in reality, which is 70 kWh a year doing nothing.
What this project does
- Measures true RMS voltage, current, real power, apparent power, power factor, frequency and cumulative energy.
- Switches the load through a 16 A relay with a schedule, a phone command, or a standby-power auto-off rule.
- Publishes a full metering payload over MQTT once per second and integrates kilowatt-hours locally.
- Detects appliance state — off, standby, active — from the power signature and reports it as a category.
- Estimates running cost from a configurable tariff, including a two-tier slab structure.
- Retains the energy counter through power cuts by writing to NVS.
- Alerts on over-current, over-voltage and unexpected standby draw.
Real-World Applications
| Setting | How it is used |
|---|---|
| Appliance auditing | Find out what is actually consuming your electricity. The results are usually not what people expect. |
| Standby elimination | Automatically cut power when a device drops below its standby threshold for ten minutes. |
| Solar self-consumption | Schedule high-draw appliances into the hours when your panels are producing. |
| Rental and shared-space billing | Per-socket kilowatt-hour records give a defensible basis for splitting a bill. |
| Fault detection | A motor whose power draw creeps up over months is a bearing failing. This catches it before it fails. |
| Server and lab equipment | Power-cycle a hung device remotely, with a record of how much it drew before it hung. |
Deployment contexts where a build of this kind earns its keep.
Features & Capabilities
- True power measurement via the PZEM-004T v3, not a current-only approximation.
- Opto-isolated Modbus-RTU link at 9600 baud — the one safety boundary in the design.
- 16 A relay with a snubber network across the contacts to suppress arcing on inductive loads.
- Non-volatile energy accumulation that survives power loss without wearing out flash.
- Appliance state classification from power thresholds with hysteresis.
- Tariff-aware cost estimation with slab rates.
- Home Assistant discovery for switch, power, energy, voltage, current and power-factor entities.
- Over-current trip in firmware that opens the relay well before the fuse would blow.
Difficulty, Time & Required Skills
| Attribute | Value |
|---|---|
| Difficulty level | Advanced |
| Estimated completion time | 10–15 hours |
| Indicative build cost | ₹3,800 – ₹4,900 |
| Primary discipline | Smart Home |
| Reference platform | ESP32 DevKit V1 (ESP-WROOM-32) |
Skills you should have (or will pick up)
- Mains wiring competence and genuine respect for it — this is not a beginner project
- Understanding of real, apparent and reactive power and power factor
- UART and the basics of Modbus-RTU register reads
- Arduino C++ with non-blocking timing
- MQTT and Home Assistant discovery
- Enclosure and creepage/clearance basics
Bill of Materials
Every part below is commonly available from Indian and international hobby-electronics suppliers. Prices are indicative 2026 retail figures in Indian rupees and will drift — treat them as a budgeting guide, not a quotation.
| Component | Key specification | Qty | Approx. cost |
|---|---|---|---|
| ESP32 DevKit V1 (ESP-WROOM-32) Wi-Fi transmit bursts peak near 500 mA — size the regulator accordingly. | Dual-core Xtensa LX6 @ 240 MHz, 520 KB SRAM, 4 MB flash, Wi-Fi 802.11 b/g/n + BLE 4.2, 34 GPIO, 18× 12-bit ADC, 2× 8-bit DAC | 1 | ₹450 |
| PZEM-004T v3 AC energy meter (100 A CT) The measurement side sits at mains potential — the opto-isolated UART is the only safe boundary. | 80–260 VAC, 0–100 A, voltage/current/power/energy/PF/frequency, ±0.5 % | 1 | ₹950 |
| 5 V single-channel opto-isolated relay module Cut the JD-VCC jumper and feed the coil separately for true isolation. | SPDT contacts rated 10 A @ 250 VAC / 10 A @ 30 VDC, opto-isolated input | 1 | ₹90 |
| 0.96″ SSD1306 OLED display Static images burn in — invert or scroll the screen periodically. | 128 × 64 monochrome, 1.3–3.3 V logic, 100 kHz–400 kHz I²C | 1 | ₹250 |
| LM2596 adjustable buck converter module Set the output voltage with no load connected before wiring the board. | 4.5–40 V in, 1.25–37 V out, 2 A (3 A peak), ~92 % efficiency | 1 | ₹90 |
| Double-sided perfboard 7 × 9 cm + headers Solder female headers so the MCU can be swapped without desoldering. | FR-4, 0.1″ pitch, plated through-holes, 24 × 18 grid | 1 | ₹60 |
| IP65 ABS junction enclosure 158 × 90 × 60 mm Fit cable glands, not drilled holes, or the IP rating means nothing. | IP65, ABS, −20 to +80 °C, transparent lid, wall-mount lugs | 1 | ₹260 |
| IEC/mains inlet + socket outlet pair | 16 A rated, screw terminals | 1 | ₹320 |
| 5 V 1 A isolated AC-DC module (HLK-PM01) Replaces the buck converter and a separate adapter — one mains feed powers everything. | 100–264 VAC in, 5 V 0.6 A out, 3 kV isolation | 1 | ₹380 |
| Snubber network (100 nF X2 + 100 Ω 2 W) Suppresses contact arcing on inductive loads; roughly triples relay life. | RC across the relay contacts | 1 | ₹60 |
| MOV (275 V varistor) + 16 A fuse and holder | S14K275 varistor, ceramic fuse | 1 | ₹120 |
| Cable glands and 1.5 mm² mains cable | PG9 glands, 3-core | 1 | ₹220 |
Estimated total: ₹3,250, excluding tools, shipping and consumables.
Tools and consumables
- Soldering iron (temperature controlled, 350 °C) with 0.8 mm 60/40 or lead-free solder
- Digital multimeter — continuity, DC volts and current ranges
- Wire strippers, flush cutters and a small set of precision screwdrivers
- Heat-shrink tubing and a heat gun (or a lighter, carefully)
- A laptop with a USB port and the toolchain listed above
Hardware Specifications
| Part | Specification | Supply | Interface | Reference |
|---|---|---|---|---|
| ESP32 DevKit V1 (ESP-WROOM-32) | Dual-core Xtensa LX6 @ 240 MHz, 520 KB SRAM, 4 MB flash, Wi-Fi 802.11 b/g/n + BLE 4.2, 34 GPIO, 18× 12-bit ADC, 2× 8-bit DAC | 3.3 V logic / 5 V USB | UART, SPI, I²C, I²S, CAN, PWM | Datasheet |
| PZEM-004T v3 AC energy meter (100 A CT) | 80–260 VAC, 0–100 A, voltage/current/power/energy/PF/frequency, ±0.5 % | 5 V logic side | UART Modbus-RTU 9600 | Datasheet |
| 5 V single-channel opto-isolated relay module | SPDT contacts rated 10 A @ 250 VAC / 10 A @ 30 VDC, opto-isolated input | 5 V coil | Digital (active-low) | Datasheet |
| 0.96″ SSD1306 OLED display | 128 × 64 monochrome, 1.3–3.3 V logic, 100 kHz–400 kHz I²C | 3.3–5 V | I²C (0x3C) | Datasheet |
| LM2596 adjustable buck converter module | 4.5–40 V in, 1.25–37 V out, 2 A (3 A peak), ~92 % efficiency | 4.5–40 V | Screw terminals + trimmer | Datasheet |
| Double-sided perfboard 7 × 9 cm + headers | FR-4, 0.1″ pitch, plated through-holes, 24 × 18 grid | — | — | Datasheet |
| IP65 ABS junction enclosure 158 × 90 × 60 mm | IP65, ABS, −20 to +80 °C, transparent lid, wall-mount lugs | — | — | Datasheet |
Consolidated electrical and interface specifications for every active part in the build.
Power Budget & Supply Sizing
Add up the typical active current of every part, then size the supply with at least 50 % headroom so transmit bursts and motor inrush never brown out the controller.
| Load | Supply rail | Typical current (mA) | Notes |
|---|---|---|---|
| ESP32 DevKit V1 (ESP-WROOM-32) | 3.3 V logic / 5 V USB | 160 | Wi-Fi transmit bursts peak near 500 mA — size the regulator accordingly. |
| PZEM-004T v3 AC energy meter (100 A CT) | 5 V logic side | 20 | The measurement side sits at mains potential — the opto-isolated UART is the only safe boundary. |
| 5 V single-channel opto-isolated relay module | 5 V coil | 70 | Cut the JD-VCC jumper and feed the coil separately for true isolation. |
| 0.96″ SSD1306 OLED display | 3.3–5 V | 20 | Static images burn in — invert or scroll the screen periodically. |
| LM2596 adjustable buck converter module | 4.5–40 V | 8 | Set the output voltage with no load connected before wiring the board. |
Summed typical draw is 278 mA. With a 1.5× design margin the supply should deliver at least 500 mA continuously at the stated rail voltage.
Software Requirements & Development Environment
Reference toolchain: Arduino IDE 2.3.x with the ESP32 board package 3.x (or PlatformIO on VS Code). Anything newer normally works; anything older may lack the board definitions used here.
- Install the Arduino IDE 2.3.x (or PlatformIO if you prefer a real editor and dependency locking).
- Add
https://espressif.github.io/arduino-esp32/package_esp32_index.jsonunder File → Preferences → Additional Board Manager URLs, then install esp32 from the Boards Manager. - Set the correct port under Tools → Port. On Linux add yourself to the
dialoutgroup:sudo usermod -aG dialout $USERand log out and back in. - Open the Serial Monitor at 115200 baud — every sketch here logs its state there.
- Keep File → Preferences → Show verbose output during: compilation switched on while you are debugging build errors.
Required libraries
| Library | Why it is needed | Install |
|---|---|---|
| WiFi (ESP32 core) bundled | Station/AP connection management for the ESP32. | Bundled with the ESP32 Arduino core |
| PubSubClient 2.8 | Lightweight MQTT 3.1.1 client for constrained devices. | Library Manager → "PubSubClient" by Nick O'Leary |
| ArduinoJson 7.x | Zero-allocation JSON serialisation and parsing. | Library Manager → "ArduinoJson" by Benoit Blanchon |
| ModbusMaster 2.0.1 | Modbus-RTU master framing for RS-485 meters and drives. | Library Manager → "ModbusMaster" by Doc Walker |
| Adafruit SSD1306 + GFX 2.5.x | Framebuffer and text/graphics primitives for the OLED. | Library Manager → "Adafruit SSD1306" |
| Preferences (NVS) bundled | Wear-levelled key/value storage in ESP32 flash for settings. | Bundled with the ESP32 core |
Block Diagram
The block diagram shows the functional decomposition of the system — what senses, what decides, what acts, and where the data ends up.
Circuit Diagram & Wiring
Every signal line in the build is shown below, followed by a pin-by-pin connection table you can work through with a multimeter in hand.
| Peripheral | Peripheral pin | Controller pin | Signal |
|---|---|---|---|
| PZEM-004T v3 | TX | GPIO 16 (RX2) | Modbus-RTU 9600 8N1 |
| PZEM-004T v3 | RX | GPIO 17 (TX2) | Through the opto-isolator |
| PZEM-004T v3 | 5V / GND | 5 V / GND | Logic side only |
| SSD1306 OLED | SDA / SCL | GPIO 21 / 22 | I²C at 0x3C |
| Relay module → live conductor | IN | GPIO 26 | Active-low |
| Status LED | Anode | GPIO 2 | Through 330 Ω |
Wire one row at a time and tick it off — most "it does not work" reports trace back to a single swapped pair.
Wiring explanation
- The PZEM current transformer goes around the live conductor only. Passing both live and neutral through the CT gives a reading of zero, because the two currents cancel. This catches almost everyone the first time.
- The PZEM voltage sense terminals connect across live and neutral upstream of the relay, so voltage is still measured when the load is switched off.
- The relay switches the live conductor, never the neutral. Switching neutral leaves the appliance live at its terminals when "off", which is exactly the hazard the switch is supposed to remove.
- Maintain at least 6 mm of creepage between mains tracks and low-voltage tracks on perfboard, and route a slot in the board between the two domains if you can. Do not run a mains track under the ESP32.
- Fit the MOV between live and neutral upstream of everything, and the fuse in the live line before the MOV. A varistor without an upstream fuse fails as a short circuit and then as a fire.
- The HLK-PM01 supplies both the ESP32 and the PZEM logic side from mains, so there is only one cable into the box. Its output is isolated from mains, but treat its input terminals as live at all times.
System Architecture
Read the stack from the bottom up: physical hardware, the firmware that drives it, the transport that moves data off the device, and the software a human actually looks at.
Working Principle
Instantaneous power is p(t) = v(t)·i(t). Real power is its average over a whole number of cycles. For a purely resistive load, current follows voltage exactly and that average equals V_rms × I_rms. For everything else it does not, and the ratio between the two is the power factor.
Two separate effects reduce power factor. Displacement is a phase shift between voltage and current, caused by inductance (motors) or capacitance. Distortion is current that is not sinusoidal at all, which is what every switch-mode power supply produces: it draws current only near the voltage peaks, in short high spikes. A laptop charger can have a power factor of 0.55 purely from distortion, with no phase shift whatsoever.
This is why a current-only measurement fails. An ACS712 tells you I_rms. Multiplying by an assumed 230 V gives apparent power in volt-amperes, not watts. For that laptop charger you would report almost twice its real consumption. The PZEM avoids this by digitising both channels and computing the true average of the product — the same thing your utility meter does.
Energy is the time integral of real power: E = ∫P dt, in joules, or in kilowatt-hours if you divide by 3.6 million. The firmware integrates numerically using the trapezoidal rule at 1 Hz, which for a signal whose meaningful changes happen over seconds is more than accurate enough. The PZEM keeps its own energy counter too, and comparing the two is a useful sanity check on your integration.
Appliance state classification exploits the fact that most devices have three distinct power regimes with large gaps between them. A television might draw 0.1 W truly off, 8 W in standby, and 90 W active. Two thresholds with hysteresis separate them robustly, and the classification is far more useful than raw watts when you are looking at a month of data.
The snubber across the relay contacts deserves a note because it is routinely omitted. When a relay opens an inductive load, the collapsing field drives the voltage across the opening contacts high enough to strike an arc, which erodes the contact material. An RC network — typically 100 nF in series with 100 Ω — gives the energy somewhere to go during the microseconds while the gap is still small. It roughly triples contact life on a motor load.
The maths behind it
Real, apparent and reactive power
P (real, W) = (1/T) ∫ v(t)·i(t) dt
S (apparent, VA) = V_rms × I_rms
Q (reactive, var)= √(S² − P²)
PF = P / S
Example — 65 W laptop charger measured:
V_rms = 231.4 V
I_rms = 0.51 A
S = 118.0 VA
P = 64.8 W (measured, not derived)
PF = 0.55
A current-only monitor would report 118 W — 82 % too high.
Energy integration
E(kWh) = Σ [ (P_n + P_(n−1)) / 2 ] × Δt / 3 600 000
At 1 Hz with P in watts and Δt = 1 s:
ΔE = P_avg / 3 600 000 kWh per sample
A 100 W load for one hour:
3600 samples × 100 / 3 600 000 = 0.100 kWh ✓
Relay and conductor sizing
Load = 16 A resistive at 230 V = 3680 W
Relay rating = 16 A @ 250 VAC → utilisation 100 % ✗
Derate: a relay rated 16 A resistive should carry
≤ 10 A continuously in an enclosed box at 40 °C.
Safe continuous load: 10 A → 2300 W
Cable: 1.5 mm² is rated ~16 A in free air,
~13 A enclosed — adequate for 10 A.
Fuse: 12 A, so it opens before the relay welds.
Program Flowchart
The firmware is a single cooperative loop. Nothing blocks for long, so networking, sensing and the user interface all stay responsive.
Assembly Instructions
Build on a breadboard first and only commit to solder once the whole system has run for an hour without a fault.
Step-by-Step Implementation Guide
Work through these in order. Each step ends in something you can observe, so a failure is always localised to the step you just finished.
Read the PZEM before touching anything else
Wire only the PZEM and the ESP32, with the PZEM measuring a known load such as a 60 W lamp on a proper extension lead. Confirm the readings are sane before you build anything into an enclosure.
cpp01-pzem-read.ino#include <ModbusMaster.h> #define PZEM_RX 16 // ESP32 RX2 <- PZEM TX #define PZEM_TX 17 // ESP32 TX2 -> PZEM RX #define PZEM_ADDR 0xF8 // factory default broadcast address ModbusMaster pzem; struct Meter { float v, i, w, wh, hz, pf; bool ok; }; void meterBegin() { Serial2.begin(9600, SERIAL_8N1, PZEM_RX, PZEM_TX); pzem.begin(PZEM_ADDR, Serial2); } Meter meterRead() { Meter m = {0, 0, 0, 0, 0, 0, false}; // Input registers 0x0000-0x0009 hold every measurement in one read. uint8_t rc = pzem.readInputRegisters(0x0000, 10); if (rc != pzem.ku8MBSuccess) return m; uint16_t r[10]; for (int i = 0; i < 10; i++) r[i] = pzem.getResponseBuffer(i); m.v = r[0] / 10.0f; // 0.1 V m.i = ((uint32_t)r[2] << 16 | r[1]) / 1000.0f; // 0.001 A, 32-bit m.w = ((uint32_t)r[4] << 16 | r[3]) / 10.0f; // 0.1 W, 32-bit m.wh = ((uint32_t)r[6] << 16 | r[5]); // 1 Wh, 32-bit m.hz = r[7] / 10.0f; // 0.1 Hz m.pf = r[8] / 100.0f; // 0.01 m.ok = true; return m; }readInputRegisters(0x0000, 10)One transaction fetches everything. Reading registers individually multiplies the bus traffic by ten and makes 1 Hz sampling marginal at 9600 baud.(uint32_t)r[2] << 16 | r[1]Current, power and energy are 32-bit values split across two 16-bit registers, low word first. Getting the word order backwards produces values that look almost plausible, which makes it a nasty bug.PZEM_ADDR 0xF8The default broadcast address works with a single meter on the bus. If you put several on one RS-485 segment, each needs a unique address written to holding register 0x0002 first.m.ok = false on failureNever treat a failed Modbus read as zero watts. It will corrupt your energy total and, worse, make a running appliance look switched off to the standby rule.Integrate energy and persist it safely
Flash has a finite erase-cycle budget. Writing the counter every second would exhaust an NVS partition in months.
cpp02-energy-accumulate.ino#include <Preferences.h> Preferences prefs; double energyKwh = 0; float lastWatts = 0; uint32_t lastSample = 0, lastPersist = 0; void energyBegin() { prefs.begin("meter", false); energyKwh = prefs.getDouble("kwh", 0.0); } void energyAccumulate(float watts) { uint32_t now = millis(); if (lastSample) { float dt = (now - lastSample) / 1000.0f; // seconds if (dt > 0 && dt < 10) { // reject huge gaps double avg = (watts + lastWatts) / 2.0; // trapezoidal energyKwh += avg * dt / 3600000.0; } } lastSample = now; lastWatts = watts; // Persist every 10 minutes, and only if the value actually moved. if (now - lastPersist > 600000UL) { lastPersist = now; double stored = prefs.getDouble("kwh", 0.0); if (fabs(energyKwh - stored) > 0.0005) prefs.putDouble("kwh", energyKwh); } } float costOf(double kwh, float slab1Rate, float slab1Units, float slab2Rate) { if (kwh <= slab1Units) return kwh * slab1Rate; return slab1Units * slab1Rate + (kwh - slab1Units) * slab2Rate; }dt > 0 && dt < 10Guards against both a millis() rollover and a long stall (a blocked Wi-Fi reconnect). Without it a single ten-second hiccup silently adds a fictitious chunk of energy.double, not floatA float has about seven significant digits. After a few thousand kilowatt-hours, adding a 0.00003 kWh increment to a float total becomes a no-op — the increment is smaller than the representable step. A double pushes that failure past any realistic lifetime.fabs(energyKwh - stored) > 0.0005Skips the flash write when nothing meaningful changed, which matters for a socket that is switched off for weeks.
Complete Source Code
The listing below is complete and compiles as written — there are no elided sections. Read the annotations under each block before you upload it.
/* ═══════════════════════════════════════════════════════════════
Smart Plug Energy Monitor — ESP32 + PZEM-004T v3 + 16 A relay
True-power metering over an opto-isolated Modbus link, local kWh
integration with NVS persistence, appliance state classification,
over-current trip, and Home Assistant discovery.
MAINS VOLTAGE. Build it, close it, and never open it powered.
══════════════════════════════════════════════════════════════════ */
#include <WiFi.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
#include <ModbusMaster.h>
#include <Wire.h>
#include <Adafruit_SSD1306.h>
#include <Preferences.h>
#include <math.h>
#define WIFI_SSID "YOUR_WIFI"
#define WIFI_PASS "YOUR_PASSWORD"
#define MQTT_HOST "192.168.1.50"
#define DEVICE_ID "plug-office"
#define PZEM_RX 16
#define PZEM_TX 17
#define PIN_RELAY 26
#define PIN_LED 2
#define TRIP_AMPS 10.0f // firmware trip, below the 12 A fuse
#define OVERVOLT 260.0f
#define STANDBY_W 15.0f // below this and above OFF_W = standby
#define OFF_W 1.0f
#define STANDBY_CUT_MS 600000UL // auto-off after 10 min in standby
#define TARIFF_1 4.50f // INR per kWh, first slab
#define SLAB_1 100.0f // units
#define TARIFF_2 6.80f
ModbusMaster pzem;
Adafruit_SSD1306 oled(128, 64, &Wire, -1);
WiFiClient net;
PubSubClient mqtt(net);
Preferences prefs;
struct Meter { float v, i, w, va, wh, hz, pf; bool ok; };
Meter meter = {};
double energyKwh = 0;
float lastWatts = 0;
uint32_t lastSample = 0, lastPersist = 0, standbySince = 0, lastPub = 0;
bool relayOn = true, tripped = false, autoStandbyCut = true;
const char *applianceState = "off";
/* ── metering ───────────────────────────────────────────────── */
Meter meterRead() {
Meter m = {};
if (pzem.readInputRegisters(0x0000, 10) != pzem.ku8MBSuccess) return m;
uint16_t r[10];
for (int i = 0; i < 10; i++) r[i] = pzem.getResponseBuffer(i);
m.v = r[0] / 10.0f;
m.i = ((uint32_t)r[2] << 16 | r[1]) / 1000.0f;
m.w = ((uint32_t)r[4] << 16 | r[3]) / 10.0f;
m.wh = ((uint32_t)r[6] << 16 | r[5]);
m.hz = r[7] / 10.0f;
m.pf = r[8] / 100.0f;
m.va = m.v * m.i;
m.ok = true;
return m;
}
void energyAccumulate(float watts) {
uint32_t now = millis();
if (lastSample) {
float dt = (now - lastSample) / 1000.0f;
if (dt > 0 && dt < 10) energyKwh += ((watts + lastWatts) / 2.0) * dt / 3600000.0;
}
lastSample = now;
lastWatts = watts;
if (now - lastPersist > 600000UL) {
lastPersist = now;
if (fabs(energyKwh - prefs.getDouble("kwh", 0.0)) > 0.0005)
prefs.putDouble("kwh", energyKwh);
}
}
/* ── protection and rules ───────────────────────────────────── */
void setRelay(bool on) {
relayOn = on;
digitalWrite(PIN_RELAY, on ? LOW : HIGH);
digitalWrite(PIN_LED, on);
}
void protectionService() {
if (!meter.ok) return;
if (meter.i > TRIP_AMPS || meter.v > OVERVOLT) {
tripped = true;
setRelay(false);
mqtt.publish("home/plug/" DEVICE_ID "/alert",
meter.i > TRIP_AMPS ? "overcurrent" : "overvoltage", true);
}
}
void classifyAndRule() {
if (!meter.ok) return;
const char *prev = applianceState;
if (meter.w < OFF_W) applianceState = "off";
else if (meter.w < STANDBY_W) applianceState = "standby";
else applianceState = "active";
if (strcmp(applianceState, "standby") != 0) { standbySince = 0; return; }
if (prev != applianceState || !standbySince) standbySince = millis();
if (autoStandbyCut && millis() - standbySince > STANDBY_CUT_MS && relayOn) {
setRelay(false);
mqtt.publish("home/plug/" DEVICE_ID "/alert", "standby-auto-off", false);
}
}
/* ── MQTT ───────────────────────────────────────────────────── */
void publishDiscovery() {
const char *base = "home/plug/" DEVICE_ID;
JsonDocument sw;
sw["name"] = "Office Plug"; sw["unique_id"] = DEVICE_ID "_sw";
sw["command_topic"] = "home/plug/" DEVICE_ID "/set";
sw["state_topic"] = "home/plug/" DEVICE_ID "/state";
sw["value_template"] = "{{ 'ON' if value_json.relay else 'OFF' }}";
char b[512]; size_t n = serializeJson(sw, b, sizeof(b));
mqtt.publish("homeassistant/switch/" DEVICE_ID "/config", (uint8_t *)b, n, true);
struct { const char *id, *name, *unit, *devcls, *field; } sensors[] = {
{ "power", "Power", "W", "power", "watts" },
{ "energy", "Energy", "kWh", "energy", "kwh" },
{ "voltage", "Voltage", "V", "voltage", "volts" },
{ "current", "Current", "A", "current", "amps" },
{ "pf", "Power Factor", "", "power_factor","pf" },
};
for (auto &s : sensors) {
JsonDocument d;
d["name"] = s.name;
d["unique_id"] = String(DEVICE_ID) + "_" + s.id;
d["state_topic"] = String(base) + "/state";
d["unit_of_measurement"] = s.unit;
d["device_class"] = s.devcls;
d["value_template"] = String("{{ value_json.") + s.field + " }}";
if (!strcmp(s.id, "energy")) d["state_class"] = "total_increasing";
else d["state_class"] = "measurement";
char buf[512]; size_t k = serializeJson(d, buf, sizeof(buf));
mqtt.publish((String("homeassistant/sensor/") + DEVICE_ID + "_" + s.id + "/config").c_str(),
(uint8_t *)buf, k, true);
}
}
void publishState() {
JsonDocument d;
d["volts"] = meter.v;
d["amps"] = meter.i;
d["watts"] = meter.w;
d["va"] = roundf(meter.va * 10) / 10.0f;
d["pf"] = meter.pf;
d["hz"] = meter.hz;
d["kwh"] = roundf(energyKwh * 1000) / 1000.0;
d["cost"] = roundf((energyKwh <= SLAB_1 ? energyKwh * TARIFF_1
: SLAB_1 * TARIFF_1 + (energyKwh - SLAB_1) * TARIFF_2) * 100) / 100.0;
d["relay"] = relayOn;
d["tripped"] = tripped;
d["state"] = applianceState;
d["comms"] = meter.ok;
char buf[320]; size_t n = serializeJson(d, buf, sizeof(buf));
mqtt.publish("home/plug/" DEVICE_ID "/state", (uint8_t *)buf, n, true);
}
void onMessage(char *topic, byte *payload, unsigned int len) {
char v[16] = {0};
memcpy(v, payload, len < 15 ? len : 15);
if (!strcmp(v, "ON")) { tripped = false; setRelay(true); }
if (!strcmp(v, "OFF")) { setRelay(false); }
if (!strcmp(v, "RESET")) { energyKwh = 0; prefs.putDouble("kwh", 0.0); }
}
/* ── setup / loop ───────────────────────────────────────────── */
void setup() {
Serial.begin(115200);
pinMode(PIN_RELAY, OUTPUT); pinMode(PIN_LED, OUTPUT);
setRelay(true);
Serial2.begin(9600, SERIAL_8N1, PZEM_RX, PZEM_TX);
pzem.begin(0xF8, Serial2);
Wire.begin(21, 22);
oled.begin(SSD1306_SWITCHCAPVCC, 0x3C);
prefs.begin("meter", false);
energyKwh = prefs.getDouble("kwh", 0.0);
WiFi.mode(WIFI_STA); WiFi.begin(WIFI_SSID, WIFI_PASS);
for (int i = 0; i < 40 && WiFi.status() != WL_CONNECTED; i++) delay(250);
mqtt.setServer(MQTT_HOST, 1883);
mqtt.setCallback(onMessage);
mqtt.setBufferSize(768);
Serial.println("Smart plug metering");
}
void loop() {
if (!mqtt.connected() && WiFi.status() == WL_CONNECTED) {
if (mqtt.connect(DEVICE_ID, NULL, NULL,
"home/plug/" DEVICE_ID "/status", 0, true, "offline")) {
mqtt.publish("home/plug/" DEVICE_ID "/status", "online", true);
mqtt.subscribe("home/plug/" DEVICE_ID "/set");
publishDiscovery();
}
}
mqtt.loop();
static uint32_t lastRead = 0;
if (millis() - lastRead >= 1000) {
lastRead = millis();
meter = meterRead();
if (meter.ok) {
energyAccumulate(meter.w);
protectionService();
classifyAndRule();
}
oled.clearDisplay();
oled.setTextColor(SSD1306_WHITE);
oled.setTextSize(2); oled.setCursor(0, 2);
oled.printf("%.0f W", meter.w);
oled.setTextSize(1);
oled.setCursor(0, 26); oled.printf("%.1f V %.3f A pf %.2f", meter.v, meter.i, meter.pf);
oled.setCursor(0, 38); oled.printf("%.3f kWh", energyKwh);
oled.setCursor(0, 50); oled.printf("%s %s%s", applianceState,
relayOn ? "ON" : "OFF", tripped ? " TRIP" : "");
oled.display();
}
if (millis() - lastPub > 1000) { lastPub = millis(); publishState(); }
}
Configuration & Calibration
Electronics: PCB, Ratings & Pin Detail
PCB information
- Keep mains and low-voltage on physically separate regions of the board with a routed slot between them if you have the tooling — the slot raises the effective creepage distance far beyond what the surface alone gives.
- Minimum 6 mm creepage and 3 mm clearance between mains and SELV circuits for 230 V basic insulation in a pollution-degree-2 environment. More is better and costs nothing on a hand-built board.
- Mains traces carrying 10 A need generous copper. At 1 oz copper, a 10 A trace wants roughly 5 mm width for a 10 °C rise — most people undersize this by a factor of three.
- Put the fuse first in the live path, then the MOV, then everything else. A MOV upstream of a fuse is a fire waiting for a surge.
- Use screw terminals rated for the conductor size, torqued properly. A loose mains terminal is the single most common cause of fires in DIY electrical work.
Power calculations
Trace width for 10 A
IPC-2221 external layer, 10 °C rise, 1 oz copper:
A (mils²) = (I / (k · ΔT^0.44))^(1/0.725), k = 0.048
A = (10 / (0.048 × 10^0.44))^(1/0.725)
= (10 / 0.1325)^1.379 ≈ 528 mils²
width = 528 / 1.378 (mils thickness) ≈ 383 mils ≈ 9.7 mm
Use 10 mm of copper, or reinforce the track with
solder or a soldered bus wire.
Relay contact derating
Rating : 16 A @ 250 VAC resistive
Enclosed box : derate 40 % → 9.6 A
Inductive : derate a further 30 % → 6.7 A
Continuous design load: 6 A (1380 W) inductive,
9 A (2070 W) resistive.
Fuse at 12 A so the fuse opens before the contacts weld.
Snubber sizing
Rule of thumb for a 230 VAC contact snubber:
C = I_load / 10 (µF, I in amperes)
R = V_load / (10 × I_load) (Ω)
For a 2 A inductive load:
C = 0.2 µF → use 100–220 nF X2-rated
R = 230 / 20 = 11.5 Ω → use 100 Ω, 2 W
The capacitor MUST be X2 safety rated. A general
purpose film capacitor across mains can fail short.
| Parameter | Rating | Design margin |
|---|---|---|
| Mains voltage | 230 V ±10 % (207–253 V) | MOV clamps at 275 V; firmware trips at 260 V. |
| Continuous current | 10 A design, 16 A relay rating | 38 % below rating — accounts for enclosure temperature rise. |
| Fuse | 12 A ceramic, HRC | Above the 10 A design load, below the relay welding threshold. |
| Creepage, mains to SELV | ≥ 6 mm | Basic insulation, pollution degree 2, per IEC 60664-1. |
| PZEM CT range | 0–100 A | Vastly over-specified for a 10 A socket, which keeps it in its linear region. |
| HLK-PM01 supply | 5 V, 600 mA, 3 kV isolation | ESP32 plus PZEM logic draws about 200 mA — a third of capacity. |
Never operate a part beyond about 70 % of its absolute maximum rating.
Pin diagram
| Pin | Function | Note |
|---|---|---|
PZEM TX | Modbus response to ESP32 RX2 (GPIO 16) | Opto-isolated on the module |
PZEM RX | Modbus request from ESP32 TX2 (GPIO 17) | Opto-isolated |
PZEM CT | Split-core current transformer | Around the LIVE conductor only |
PZEM V+/V− | Voltage sense | Upstream of the relay |
Relay COM/NO | Switches the live conductor | Never switch neutral |
Testing Procedure & Expected Output
Test from the bottom up. Confirm power, then each sensor in isolation, then the integrated loop — the first failing step tells you exactly where to look.
| Test | What you should see |
|---|---|
| Read the meter with no load | Voltage 220–250 V, frequency 49.8–50.2 Hz, current under 0.02 A, power under 1 W. |
| Plug in a 60 W incandescent lamp | About 60 W with a power factor above 0.98 — a resistive load, so real and apparent power nearly match. |
| Plug in a laptop charger | Power factor between 0.5 and 0.7, with apparent power well above real power. This is the demonstration that makes the whole project worthwhile. |
| Run a known load for exactly one hour | Integrated kWh within about 1 % of load watts ÷ 1000, and matching the PZEM's own counter. |
| Publish OFF over MQTT | The relay clicks, power drops to zero, and voltage continues to be reported (it is sensed upstream). |
| Leave a television in standby for eleven minutes | The standby rule fires and the plug switches itself off, with an alert published. |
| Power-cycle the plug | The kWh counter resumes within 0.001 kWh of its previous value. |
| Disconnect the PZEM UART | comms goes false and the display shows stale values — it must not report zero watts. |
Bench-test checklist. If a row fails, stop and fix it before moving on.
Expected output
With everything wired and the firmware uploaded, the Serial Monitor at 115200 baud should look similar to the trace below. Values will differ; the shape of the output should not.
Troubleshooting: Common Errors & Fixes
Performance Optimisation
- Read all ten registers in one Modbus transaction. Ten separate reads at 9600 baud take about 300 ms and make 1 Hz sampling unreliable.
- Publish at 1 Hz but persist at 1/600 Hz. Matching those rates is what wears out flash.
- Use a double for the energy accumulator. Float precision loss after a few thousand kilowatt-hours is a real, silent failure.
- Replace every
delay()with amillis()comparison — blocking delays are the single most common cause of dropped readings. - Sample sensors on a fixed cadence and publish on a slower one; you almost never need to transmit at the sampling rate.
- Move networking into its own FreeRTOS task so a slow DNS lookup cannot stall the control loop.
- Use
uint8_t/uint16_twhere the range allows; on an 8-bit AVR a 32-bit add costs four times as much. - For battery builds use deep sleep between samples: an ESP32 drops from ~160 mA awake to about 10 µA asleep, which is the difference between days and months of runtime.
- Profile before optimising — print
micros()deltas around each stage and fix the slowest one first.
Safety Precautions
- This project handles mains voltage. If you are not competent and confident with 230 V wiring, build the metering half only and use a certified smart plug for switching.
- Never open the enclosure while it is plugged in. Never probe the mains side with an earthed oscilloscope — you will short live to earth through the probe ground.
- Fit the fuse in the live conductor, upstream of everything including the MOV.
- Use only an X2-rated capacitor in the snubber. A general-purpose film capacitor across mains can fail short circuit.
- The finished unit must be fully enclosed with cable glands and no accessible conductive parts. A test build on a bench with exposed terminals is a genuine electrocution hazard, not a theoretical one.
- Have the finished assembly checked by a qualified electrician before it carries a real load unattended.
- Mains voltage kills. Anything on the load side of the relay is at 230 V. Do not work on a powered circuit, and never leave exposed mains wiring on a bench where someone could touch it.
- Keep at least 6 mm of creepage between the mains and low-voltage sides of any board you make, and never route mains tracks under the microcontroller.
- Have a qualified electrician do the final installation into a consumer unit or wall fitting. In most jurisdictions this is a legal requirement, not a suggestion.
- Fit an RCD/RCBO upstream and fuse the load appropriately for its rating.
- Wear eye protection when soldering or cutting, and solder in a ventilated space — rosin flux fumes are a respiratory irritant.
- Power the circuit through a bench supply with a current limit while you are testing. A 300 mA limit turns a wiring mistake into a beep instead of a dead board.
- Disconnect power before changing any wiring. Hot-plugging a sensor onto a live bus is the fastest way to lose a controller.
Maintenance
- Re-check every screw terminal and header after the first week — thermal cycling loosens connections that felt tight on day one.
- Recalibrate at the interval given in the calibration section, and keep the constants in a text file next to the firmware — not only in flash.
- Keep a short logbook of firmware versions and what changed. Six months later you will not remember why that constant is 1.083.
Future Improvements & Upgrades
A working v1 is a platform, not a finish line. These are the upgrades that add the most capability for the least rework.
- Add harmonic analysis with a dedicated energy IC such as the ATM90E32AS, which reports individual harmonics rather than just power factor.
- Add appliance disaggregation — a small classifier on the power signature can identify which appliance is plugged in from its startup transient.
- Add a zero-cross detector and switch the relay at the voltage zero crossing, which nearly eliminates contact arcing.
- Replace the relay with a triac and opto-isolated driver for silent, unlimited-cycle switching (accepting the leakage current and heatsinking that brings).
- Add local storage so a month of per-minute data survives a broker outage.
- Design a proper PCB. Once the breadboard version has run for a month, moving to a two-layer board removes the intermittent-contact failures that dominate prototype faults.
- Add connectivity — an ESP32 and an MQTT publish turn a local gadget into something you can graph, alert on and analyse over months.
- Add persistent local storage (microSD or the on-chip flash) so a network outage does not create a hole in your data.
- Move configuration out of the source: a captive-portal setup page or a JSON config file makes the build reusable without a recompile.
- Add a battery and solar option so the unit survives a power cut and can be sited away from a socket.
- Write a small test harness that feeds synthetic sensor values through the decision logic, so you can validate thresholds without physically triggering the event.
Frequently Asked Questions
References & Learning Resources
These are the primary sources worth reading in full. Manufacturer datasheets always outrank forum posts when the two disagree.
- PZEM-004T v3.0 — user manual and Modbus register mapPeacefair / Innovators Guru
- Modbus Application Protocol Specification V1.1b3Modbus Organization
- IEC 60664-1 — insulation coordination, creepage and clearanceIEC
- IPC-2221 generic standard on printed board design — trace current capacityIPC
- Real, reactive and apparent power — AC power theoryWikipedia
- Home Assistant MQTT Sensor — device_class and state_class referenceHome Assistant
- Relay contact protection and snubber designTE Connectivity application note