Community project
// // Nilm Whole-home Energy Metering Firmware /

This guide builds a non-invasive whole-home energy meter using an ESP32, dual PZEM-004T energy sensors, and an ADS1115 ADC for high-resolution current sampling. The system monitors main and sub-circuit power consumption in real time, displaying live readings on an OLED screen while streaming data via MQTT to a local broker for logging and analysis.
The assembly covers safety-first mains isolation, DIN-rail mounting, non-invasive CT sensor placement, and firmware deployment. Readers will receive a complete wiring diagram, parts list, step-by-step assembly instructions, and pre-configured firmware that publishes JSON energy data (voltage, current, power, frequency) to MQTT topics compatible with home automation platforms and data visualization tools.
Wiring diagram
Interactive · read-only
Pan and zoom to explore the wiring. Remix the project to edit it in your own workspace.
Parts list
Bill of materials| Component | Qty | Notes |
|---|---|---|
| PZEM-004T v3.0 Energy Meter | 1 | Peacefair single-phase AC energy metering module. Measures V, I, W, VAr, PF, Hz, kWh. Communicates via Modbus-RTU over TTL UART at 9600 baud. Self-powered from 230V AC mains. Built-in 100A CT clamp included. Optocoupler-isolated UART. 80–260VAC range. |
| ADS1115 16-Bit ADC Module | 1 | 16-bit four-channel I2C analog-to-digital converter with programmable gain amplifier. Common ADS1115 breakouts expose MCU-facing SDA/SCL pins and four analog inputs for single-ended or differential measurements. |
| PZEM-004T v3.0 Energy Meter (Sub-circuit) | 1 | Second PZEM-004T v3.0 for optional sub-circuit monitoring. Uses Modbus address 0x02, shares the same UART bus as pzem (Modbus multi-drop). Same wiring as primary PZEM but with independent CT clamp on a sub-circuit live wire. |
| SSD1306 128x64 OLED Display (I2C) | 1 | 0.96-inch monochrome OLED for local real-time energy readout. I2C address 0x3C. |
| BSS138 Bidirectional Logic Level Shifter (4-ch) | 1 | 4-channel bidirectional 3.3V↔5V level shifter. Used to adapt ESP32 3.3V UART to PZEM-004T 5V UART for robust communication. Shift channels: A1/A2 for UART TX/RX. |
| Hi-Link HLK-5M05 (230VAC to 5VDC, 5W) | 1 | Compact 230VAC to 5VDC isolated SMPS module. Provides 5V/1A (5W) to power the ESP32 DevKit (via its 5V/VIN pin) and both PZEM modules' logic side. CE/UL approved for mains use. |
Assembly
10 stepsSafety first — isolate the mains
Turn off the main circuit breaker at the distribution board before doing ANY wiring. Verify zero voltage with a non-contact voltage tester on every terminal you will touch. Nepal 230V AC is lethal. Work with a qualified electrician for the mains connections (V+ / V- / L / N terminals).
- ⚠ NEVER work on live mains.
- ⚠ Double-check with a voltage tester before touching any terminal.
- ⚠ All AC wiring to PZEM V+/V- and HiLink L/N must use rated 300V flex cable and insulated ferrule terminals.
Mount the enclosure and DIN-rail components
Use a plastic DIN-rail enclosure (at least IP20 for indoor panel use). Mount the Hi-Link HLK-5M05 PSU and the two PZEM-004T v3.0 modules on the DIN rail. Leave enough space for the ESP32 DevKit on a standoff or breadboard inside the enclosure.
- Tip: A 150×100×70 mm DIN rail enclosure with 2 DIN slots fits comfortably.
Wire 230V AC mains to Hi-Link PSU and PZEM voltage inputs
Using the main breaker still OFF: 1. Run a short 1.5mm² flex wire from the Live busbar to: Hi-Link L terminal, PZEM-1 V+ terminal, PZEM-2 V+ terminal (all in parallel via screw terminals or wago connectors). 2. Run Neutral busbar wire to: Hi-Link N terminal, PZEM-1 V- terminal, PZEM-2 V- terminal.
- ⚠ Use only insulated 300V-rated wire for all AC connections.
- ⚠ Ensure all screw terminals are fully tightened.
- ⚠ PZEM modules MUST be connected to mains — the metering SoC is mains-powered; the 5V pin only powers the UART optocouplers.
Clamp CT sensors on live wires (Non-Invasive)
Open each CT clamp and snap it around the LIVE wire only (never enclose both live and neutral in the same clamp). - CT-1 (with pzem): clamp on the main incomer Live wire after the main breaker. - CT-2 (with pzem2): clamp on the live wire of a sub-circuit breaker you want to monitor. Connect each CT's 3.5mm jack to the PZEM CT screw terminals (CT+ / CT−). If your CT has bare wires, observe polarity: arrow on the CT points toward the load.
- Tip: CT clamp polarity determines the sign of current reading — if power reads negative or near-zero, flip the CT orientation.
- ⚠ Clamp only the LIVE conductor, not Live+Neutral together.
Connect Hi-Link 5V output to the DC rail
From Hi-Link 5V_OUT terminal, run a wire to a small distribution block providing: - ESP32 DevKit VIN pin (5V in). - PZEM-1 5V pin. - PZEM-2 5V pin. From Hi-Link GND_OUT, run to a common GND strip shared by ESP32 GND, PZEM-1 GND, PZEM-2 GND, ADS1115 GND, OLED GND, and level-shifter GND.
- Tip: Use 0.5mm² wire for DC 5V runs inside the enclosure.
Wire the BSS138 logic level shifter
The level shifter bridges ESP32 (3.3V) UART to PZEM (5V) UART: 1. LV pin → 3.3V rail (ESP32 3V3 pin). 2. HV pin → 5V rail. 3. GND → common GND. 4. A1 (LV side) → ESP32 GPIO17 (TX2). 5. B1 (HV side) → PZEM-1 RX AND PZEM-2 RX (both in parallel on the same Modbus bus). 6. A2 (LV side) → ESP32 GPIO16 (RX2). 7. B2 (HV side) → PZEM-1 TX AND PZEM-2 TX (both in parallel).
- Tip: Both PZEMs share one Modbus RTU bus. PZEM-2 must be pre-programmed to address 0x02 (use the SetAddress sketch from PZEM004Tv30 library with ONLY PZEM-2 connected before parallel wiring).
Connect ADS1115 ADC module (I2C)
Wire the ADS1115 breakout: 1. VDD → 3.3V rail. 2. GND → GND rail. 3. SDA → ESP32 GPIO21 (shared I2C bus). 4. SCL → ESP32 GPIO22 (shared I2C bus). 5. ADDR → GND (sets I2C address 0x48). For each SCT-013-000 CT clamp connected to ADS1115: - Build a simple bias + burden circuit per channel: a 33Ω resistor across the CT output, plus two 10kΩ resistors from 3.3V to GND forming a 1.65V mid-rail bias, tied to AIN0 (and AIN1, AIN2 for additional channels).
- Tip: The ADS1115 adds high-frequency sampling capability for NILM event detection at 10+ Hz, complementing PZEM's 1Hz Modbus polling.
- Tip: Use twisted-pair wiring from CT output to ADS1115 input to minimise noise.
Connect SSD1306 OLED display
Wire the OLED: 1. VCC → 3.3V rail. 2. GND → GND rail. 3. SDA → ESP32 GPIO21 (shared I2C bus with ADS1115). 4. SCL → ESP32 GPIO22. Both OLED (0x3C) and ADS1115 (0x48) have different I2C addresses so they coexist on the same bus.
- Tip: Secure the OLED with double-sided tape or a 3D-printed bracket on the enclosure lid for easy viewing.
Edit firmware credentials, then deploy
Before deploying, open nilm_meter.ino and update: - WIFI_SSID and WIFI_PASSWORD for your local network. - MQTT_HOST with the IP address of your local Mosquitto broker PC. Then use Schematik's Deploy button to compile and flash the firmware to the ESP32.
- Tip: On the broker PC, run: mosquitto_sub -h localhost -t 'nilm/#' -v to verify JSON messages arriving every 2 seconds.
- Tip: For InfluxDB ingestion, use Node-RED MQTT-in → influxdb-out nodes, or a Telegraf MQTT consumer plugin.
Power on and verify
1. Restore the main circuit breaker. 2. The Hi-Link PSU will power up and the ESP32 will boot — OLED shows 'NILM Meter v1.0 / Booting...'. 3. Within 5–10 seconds the OLED updates with live V / A / W / kWh readings. 4. Check Serial Monitor (115200 baud via USB) for PZEM read confirmations and MQTT connection status. 5. Verify readings: voltage should be 220–235 V, frequency ~50 Hz.
- ⚠ If PZEM reads 0.0V: confirm V+/V- are connected to mains and CT clamp is on the live wire.
- ⚠ If MQTT shows 'FAILED': verify broker IP, ensure both ESP32 and broker PC are on the same LAN subnet.
- ⚠ If OLED shows blank: check I2C address (try 0x3D instead of 0x3C on some modules).
Firmware
ESP32// ============================================================
// NILM Whole-Home Energy Metering Firmware
// Board : ESP32 DevKit v1
// Sensors : PZEM-004T v3.0 (main) + optional 2nd PZEM
// ADS1115 (optional high-rate SCT-013 sampling)
// Output : MQTT JSON → local MQTT broker (e.g. Mosquitto)
// or HTTP POST → InfluxDB / CSV endpoint
// Display : SSD1306 128×64 OLED (live readout)
// Grid : 230 V / 50 Hz (Nepal NEC standard)
// ============================================================
#include <Arduino.h>
#include <Wire.h>
#include <WiFi.h>
#include <HardwareSerial.h>
#include <PZEM004Tv30.h>
#include <Adafruit_ADS1X15.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
// ── Pin Definitions ────────────────────────────────────────
#define PZEM_RX 16 // ESP32 RX2 ← PZEM TX (via level shifter A2 / B2)
#define PZEM_TX 17 // ESP32 TX2 → PZEM RX (via level shifter A1 / B1)
// I2C: SDA=GPIO21, SCL=GPIO22 (ESP32 defaults, shared by ADS1115 + OLED)
// ── WiFi Credentials ───────────────────────────────────────
#define WIFI_SSID "YOUR_SSID"
#define WIFI_PASSWORD "YOUR_PASSWORD"
// ── MQTT Broker ────────────────────────────────────────────
#define MQTT_HOST "192.168.1.100" // IP of your local Mosquitto broker
#define MQTT_PORT 1883
#define MQTT_CLIENT "nilm_meter_01"
#define MQTT_TOPIC_MAIN "nilm/main/power"
#define MQTT_TOPIC_SUB "nilm/sub/power"
#define MQTT_TOPIC_ADS "nilm/ads/current"
// ── Sampling Intervals ─────────────────────────────────────
#define PZEM_INTERVAL_MS 2000 // PZEM poll: ~2 s (PZEM internal settling ≥1.2 s)
#define ADS_INTERVAL_MS 100 // ADS1115 high-rate sampling: 100 ms (10 Hz)
#define OLED_INTERVAL_MS 1000 // OLED refresh: 1 s
// ── OLED ───────────────────────────────────────────────────
#define SCREEN_W 128
#define SCREEN_H 64
#define OLED_ADDR 0x3C
// ── ADS1115 ────────────────────────────────────────────────
#define ADS_ADDR 0x48 // ADDR pin → GND
// SCT-013 burden resistor 33 Ω + 2×10 kΩ voltage divider bias at 1.65 V
// ADS1115 ±2.048 V range (PGA = 2), 16-bit → 0.0625 mV/LSB
// Current scale: SCT-013-030 = 30A/1V ↔ 30A over 1V across 33Ω? Actually
// SCT-013-000 (0-100A, 50mA secondary) → I_sec = I_pri / 2000
// V_burden = I_sec × 33Ω → I_pri = V_ads / 33 × 2000 (adjust to your CT ratio)
#define SCT_TURNS_RATIO 2000 // SCT-013-000: 100A primary / 50mA secondary
#define SCT_BURDEN_OHM 33.0f
#define ADS_PGA_SCALE 0.0000625f // V per LSB at PGA=±2.048V (2.048/32768)
// ── PZEM Modbus addresses ──────────────────────────────────
#define PZEM_ADDR_MAIN 0xF8 // default broadcast (single device)
#define PZEM_ADDR_SUB 0x02 // set via PZEM-004T-v30 SetAddress sketch first
// ══════════════════════════════════════════════════════════
// Globals
// ══════════════════════════════════════════════════════════
struct PzemData {
float voltage = 0;
float current = 0;
float power = 0;
float energy = 0;
float frequency = 0;
float pf = 0;
bool valid = false;
};
// Forward declarations
void connectWifi();
void ensureMqtt();
void publishPzem(const char* topic, const PzemData& d, uint8_t addr);
void publishAds();
float measureAdsRms(uint8_t channel, uint16_t samples);
void updateOled();
HardwareSerial pzemSerial(2);
PZEM004Tv30 pzem_main(pzemSerial, PZEM_RX, PZEM_TX, PZEM_ADDR_MAIN);
PZEM004Tv30 pzem_sub (pzemSerial, PZEM_RX, PZEM_TX, PZEM_ADDR_SUB);
Adafruit_ADS1115 ads;
Adafruit_SSD1306 display(SCREEN_W, SCREEN_H, &Wire);
WiFiClient wifiClient;
PubSubClient mqtt(wifiClient);
// Latest PZEM readings — main circuit
PzemData mainData, subData;
// ADS1115 RMS buffer (compute RMS over one cycle at 50 Hz)
static const uint16_t ADS_SAMPLES_PER_CYCLE = 20; // 10 Hz × 2 cycles gives some averaging
float adsRms[4] = {0, 0, 0, 0};
unsigned long lastPzemMs = 0;
unsigned long lastAdsMs = 0;
unsigned long lastOledMs = 0;
unsigned long lastMqttMs = 0;
// ══════════════════════════════════════════════════════════
// Helper: connect WiFi
// ══════════════════════════════════════════════════════════
void connectWifi() {
Serial.printf("Connecting to WiFi: %s\n", WIFI_SSID);
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
uint8_t attempts = 0;
while (WiFi.status() != WL_CONNECTED && attempts < 30) {
delay(500);
Serial.print('.');
attempts++;
}
if (WiFi.status() == WL_CONNECTED) {
Serial.printf("\nWiFi connected. IP: %s\n", WiFi.localIP().toString().c_str());
} else {
Serial.println("\nWiFi FAILED — running in offline mode.");
}
}
// ══════════════════════════════════════════════════════════
// Helper: MQTT reconnect (non-blocking attempt)
// ══════════════════════════════════════════════════════════
void ensureMqtt() {
if (WiFi.status() != WL_CONNECTED) return;
if (mqtt.connected()) return;
Serial.print("MQTT connecting...");
if (mqtt.connect(MQTT_CLIENT)) {
Serial.println(" OK");
} else {
Serial.printf(" FAILED rc=%d\n", mqtt.state());
}
}
// ══════════════════════════════════════════════════════════
// Helper: publish a PzemData struct as JSON
// ══════════════════════════════════════════════════════════
void publishPzem(const char* topic, const PzemData& d, uint8_t addr) {
if (!mqtt.connected()) return;
StaticJsonDocument<256> doc;
doc["ts"] = millis(); // ms uptime; replace with NTP epoch in production
doc["addr"] = addr;
doc["V"] = d.voltage;
doc["A"] = d.current;
doc["W"] = d.power;
doc["VAr"] = d.power * (d.pf > 0.001f ? sqrtf(1.0f - d.pf * d.pf) / d.pf : 0);
doc["PF"] = d.pf;
doc["Hz"] = d.frequency;
doc["kWh"] = d.energy;
doc["valid"]= d.valid;
char buf[256];
serializeJson(doc, buf, sizeof(buf));
mqtt.publish(topic, buf);
}
// ══════════════════════════════════════════════════════════
// Helper: publish ADS1115 RMS currents
// ══════════════════════════════════════════════════════════
void publishAds() {
if (!mqtt.connected()) return;
StaticJsonDocument<192> doc;
doc["ts"] = millis();
for (uint8_t ch = 0; ch < 4; ch++) {
char key[8];
snprintf(key, sizeof(key), "A_ch%u", ch);
doc[key] = adsRms[ch];
}
char buf[192];
serializeJson(doc, buf, sizeof(buf));
mqtt.publish(MQTT_TOPIC_ADS, buf);
}
// ══════════════════════════════════════════════════════════
// Helper: compute RMS current from ADS1115 channel
// Accumulates SAMPLE_N readings, returns RMS amp value
// ══════════════════════════════════════════════════════════
float measureAdsRms(uint8_t channel, uint16_t samples) {
ads.setGain(GAIN_TWO); // ±2.048 V
double sum = 0;
for (uint16_t i = 0; i < samples; i++) {
int16_t raw = ads.readADC_SingleEnded(channel);
float v = raw * ADS_PGA_SCALE;
// Remove DC bias (1.65 V mid-rail): subtract ~26400 raw (1.65/0.0000625)
// We use AC coupling assumption: mean removal in sliding window not shown here
// For simplicity, square and accumulate (bias cancels in differential config)
sum += (double)v * v;
delayMicroseconds(200); // ~5 kHz effective sample rate
}
float vrms = sqrtf((float)(sum / samples));
// Convert V_rms → I_primary_rms
return (vrms / SCT_BURDEN_OHM) * SCT_TURNS_RATIO;
}
// ══════════════════════════════════════════════════════════
// OLED display update
// ══════════════════════════════════════════════════════════
void updateOled() {
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
display.setCursor(0, 0);
if (mainData.valid) {
display.printf("V:%.1fV Hz:%.1f\n", mainData.voltage, mainData.frequency);
display.printf("I:%.2fA PF:%.2f\n", mainData.current, mainData.pf);
display.printf("P:%.1fW\n", mainData.power);
display.printf("E:%.3fkWh\n", mainData.energy);
} else {
display.println("Main: No data");
}
display.drawLine(0, 34, 127, 34, SSD1306_WHITE);
display.setCursor(0, 37);
if (subData.valid) {
display.printf("Sub %.1fW %.3fkWh\n", subData.power, subData.energy);
} else {
display.println("Sub: No data");
}
display.setCursor(0, 50);
display.printf("WiFi:%s MQTT:%s",
WiFi.status() == WL_CONNECTED ? "OK" : "--",
mqtt.connected() ? "OK" : "--");
display.display();
}
// ══════════════════════════════════════════════════════════
// Setup
// ══════════════════════════════════════════════════════════
void setup() {
Serial.begin(115200);
delay(500);
Serial.println("\n=== NILM Energy Meter Booting ===");
// I2C (SDA=21, SCL=22 — ESP32 defaults)
Wire.begin(21, 22);
// OLED
if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR)) {
Serial.println("OLED init FAILED — continuing without display.");
} else {
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
display.setCursor(0, 0);
display.println("NILM Meter v1.0");
display.println("Booting...");
display.display();
}
// ADS1115
ads.setDataRate(RATE_ADS1115_860SPS);
if (!ads.begin(ADS_ADDR)) {
Serial.println("ADS1115 init FAILED — SCT-013 channels disabled.");
} else {
Serial.println("ADS1115 OK");
}
// PZEM UART2
pzemSerial.begin(9600, SERIAL_8N1, PZEM_RX, PZEM_TX);
delay(100);
Serial.println("PZEM UART2 started.");
// WiFi
connectWifi();
// MQTT
mqtt.setServer(MQTT_HOST, MQTT_PORT);
mqtt.setKeepAlive(30);
mqtt.setSocketTimeout(5);
ensureMqtt();
Serial.println("Setup complete. Entering loop.");
}
// ══════════════════════════════════════════════════════════
// Loop
// ══════════════════════════════════════════════════════════
void loop() {
unsigned long now = millis();
// 1. MQTT keep-alive
if (WiFi.status() == WL_CONNECTED) {
ensureMqtt();
mqtt.loop();
}
// 2. Poll PZEM sensors every PZEM_INTERVAL_MS
if (now - lastPzemMs >= PZEM_INTERVAL_MS) {
lastPzemMs = now;
// ── Main circuit ──────────────────────────────────
mainData.voltage = pzem_main.voltage();
mainData.current = pzem_main.current();
mainData.power = pzem_main.power();
mainData.energy = pzem_main.energy();
mainData.frequency = pzem_main.frequency();
mainData.pf = pzem_main.pf();
mainData.valid = !isnan(mainData.voltage) && !isnan(mainData.current);
if (mainData.valid) {
Serial.printf("[MAIN] V=%.1f A=%.3f W=%.1f PF=%.2f Hz=%.1f kWh=%.3f\n",
mainData.voltage, mainData.current, mainData.power,
mainData.pf, mainData.frequency, mainData.energy);
publishPzem(MQTT_TOPIC_MAIN, mainData, PZEM_ADDR_MAIN);
} else {
Serial.println("[MAIN] PZEM read error — check wiring and mains connection.");
}
// ── Sub-circuit (optional) ────────────────────────
subData.voltage = pzem_sub.voltage();
subData.current = pzem_sub.current();
subData.power = pzem_sub.power();
subData.energy = pzem_sub.energy();
subData.frequency = pzem_sub.frequency();
subData.pf = pzem_sub.pf();
subData.valid = !isnan(subData.voltage) && !isnan(subData.current);
if (subData.valid) {
Serial.printf("[SUB] V=%.1f A=%.3f W=%.1f PF=%.2f Hz=%.1f kWh=%.3f\n",
subData.voltage, subData.current, subData.power,
subData.pf, subData.frequency, subData.energy);
publishPzem(MQTT_TOPIC_SUB, subData, PZEM_ADDR_SUB);
}
// No-error print for sub; it may simply be absent
}
// 3. ADS1115 high-rate RMS sampling every ADS_INTERVAL_MS
if (now - lastAdsMs >= ADS_INTERVAL_MS) {
lastAdsMs = now;
// Sample each channel; 50 samples each at ~200µs → ~10 ms per channel
for (uint8_t ch = 0; ch < 4; ch++) {
adsRms[ch] = measureAdsRms(ch, 50);
}
publishAds();
}
// 4. OLED refresh every OLED_INTERVAL_MS
if (now - lastOledMs >= OLED_INTERVAL_MS) {
lastOledMs = now;
updateOled();
}
// 5. WiFi reconnect watchdog (every 30 s)
static unsigned long lastWifiCheck = 0;
if (now - lastWifiCheck >= 30000) {
lastWifiCheck = now;
if (WiFi.status() != WL_CONNECTED) {
Serial.println("WiFi lost — reconnecting...");
WiFi.reconnect();
}
}
}“Deploy to device” opens this project in Schematik, where you can flash it to your board over USB.
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.