Community project

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

Bishrant Ghimire

Published July 15, 2026

ESP326 components10 assembly steps
Remix this project
Photo of // // 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
Wiring diagram for // // Nilm Whole-home Energy Metering Firmware /

Pan and zoom to explore the wiring. Remix the project to edit it in your own workspace.

Parts list

Bill of materials
ComponentQtyNotes
PZEM-004T v3.0 Energy Meter1Peacefair 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 Module116-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)1Second 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)10.96-inch monochrome OLED for local real-time energy readout. I2C address 0x3C.
BSS138 Bidirectional Logic Level Shifter (4-ch)14-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)1Compact 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 steps
  1. Safety 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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).
  7. 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.
  8. 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.
  9. 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.
  10. 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_meter.inoDeploy to device
// ============================================================
//  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.

Open in Schematik