Preview build — page text is a draft awaiting human review. Code badges come from real compiles. The flasher is real but not yet hardware-tested; the Playground is still a preview.

PZEM-004T energy meter with Raspberry Pi Pico 2 W — MicroPython Modbus (electrician-only mains side)

Read voltage, current, power, kWh, frequency and PF from a PZEM-004T v3.0 on Pico 2 W UART0 in MicroPython. The 230 V side is for a licensed electrician only.

MicroPythonVerified — micropython (mpy-cross v1.29.0), 3 Nov 2026Compile-only

Community draft — report a mistake

The PZEM-004T v3.0 measures a 230 V circuit — voltage, current, power, energy, frequency and power factor — and reports it over an isolated serial port. The Pico 2 W reads it on UART0 (GP0/GP1) with a few lines of Modbus-RTU, ready to log or send over Wi-Fi.

This guide covers only the low-voltage side: 5 V from VBUS, RX/TX crossed, and a 1 kΩ / 2 kΩ divider on the PZEM’s TX. Connecting L, N and the current transformer to the mains is electrician-only work.

Wiring

Raspberry Pi Pico 2 WOnly the pins used are shown · 3.3 V logicPZEM-004T v3.0 AC energy meter (100 A with CT)1k│2kGP0GP1GNDVBUS5VRXTXGNDL → Mains live — electrician only, via MCBN → Mains neutral — electrician onlyCT1 → Current transformer (clamped on the live wire only)CT2 → Current transformer (other lead)
Also needed: 1 kΩ resistor, 2 kΩ resistor, Closed, insulated enclosure for the PZEM.
Every connection
Raspberry Pi Pico 2 WPZEM-004T v3.0 AC energy meter (100 A with CT)WireNotes
—L—To Mains live — electrician only, via MCB (not drawn)
—N—To Mains neutral — electrician only (not drawn)
—CT1—To Current transformer (clamped on the live wire only) (not drawn)
—CT2—To Current transformer (other lead) (not drawn)
VBUS5VredVBUS (pin 40), USB 5 V for the opto-isolated TTL side.
GP0RXgreenGP0 = UART0 TX, physical pin 1 → PZEM RX.
GP1TXyellowVia 1 kΩ / 2 kΩ divider — PZEM TX is pulled up to 5 V. 1 kΩ from TX to GP1, 2 kΩ from GP1 to GND. GP1 = UART0 RX, physical pin 2.
GNDGNDblackTTL-side ground only — it is isolated from the mains side.

TTL side 5 V from your board; measuring side powered from the 230 V it measures part, 3.3 V board. The TTL side is opto-isolated and powered from your board. Its TX line is pulled up to whatever you feed the 5V pin: on ESP32/Pico, keep 5 V and put a 1 kΩ / 2 kΩ divider on PZEM TX → board RX (many v3.0 units also work with 3.3 V on the 5V pin). On Uno connect directly.

Board quirks

  • Mains safety: L, N and the CT are on 230 V. Wiring them is a job for a licensed electrician, with the supply off at the MCB, behind an RCCB, inside a closed enclosure, with proper earthing. Never touch the PZEM board while L/N are live.
  • You only wire the TTL side: 5V, GND, RX and TX. It is opto-isolated from the mains side — keep it that way, and never join its GND to mains earth or neutral.
  • PZEM TX is pulled up to its 5V pin. The Pico 2 W’s inputs tolerate 5 V only while the board is powered, so the divider stays in.
  • No library: the script sends one Modbus-RTU “read input registers” request (address 0xF8 reaches a single PZEM) and checks the CRC-16 of the reply.
  • The CT must clamp the live wire only. Clamping the whole cable (L and N together) reads zero.

Code

main.pyMicroPython
# PZEM-004T v3.0 energy meter on a Raspberry Pi Pico 2 W (MicroPython, Modbus-RTU, no library).
# Low-voltage side only: 5V -> VBUS (pin 40), GND -> GND, PZEM RX <- GP0 (pin 1, UART0 TX),
# PZEM TX -> 1 kOhm -> GP1 (pin 2, UART0 RX), and 2 kOhm from GP1 to GND.
# The L/N terminals and the CT are on 230 V mains: wiring them is a job for a licensed electrician,
# inside a closed enclosure, behind an MCB/RCCB, with the supply switched off.
from machine import UART, Pin
import struct
import time

uart = UART(0, baudrate=9600, tx=Pin(0), rx=Pin(1), timeout=200)
led = Pin("LED", Pin.OUT)


def crc16(data):  # Modbus CRC-16: init 0xFFFF, reflected polynomial 0xA001
    crc = 0xFFFF
    for b in data:
        crc ^= b
        for _ in range(8):
            crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
    return crc


def read_all(addr=0xF8):
    """Read the 10 input registers. 0xF8 = 'whoever is listening' (one PZEM on the bus)."""
    req = bytes([addr, 0x04, 0x00, 0x00, 0x00, 0x0A])
    req += struct.pack("<H", crc16(req))
    uart.read()  # drop anything stale
    uart.write(req)
    resp = uart.read(25)  # addr, func, count(20), 20 data bytes, CRC
    if not resp or len(resp) != 25:
        return None
    if crc16(resp[:23]) != struct.unpack("<H", resp[23:])[0] or resp[1] != 0x04:
        return None
    r = struct.unpack(">10H", resp[3:23])
    return {
        "volts": r[0] / 10,
        "amps": (r[1] | (r[2] << 16)) / 1000,
        "watts": (r[3] | (r[4] << 16)) / 10,
        "kwh": (r[5] | (r[6] << 16)) / 1000,
        "hz": r[7] / 10,
        "pf": r[8] / 100,
    }


print("PZEM-004T on UART0 (GP0/GP1) - the module only answers while its L/N side has mains")
while True:
    m = read_all()
    if m is None:
        led.off()
        print("No answer - mains off on L/N, TX/RX not crossed, or divider missing")
    else:
        led.toggle()
        print("{volts:.1f} V  {amps:.3f} A  {watts:.1f} W  PF {pf:.2f}  {hz:.1f} Hz  total {kwh:.3f} kWh".format(**m))
    time.sleep(2)

Expected serial output

REPL / Thonny shellOutput
PZEM-004T on UART0 (GP0/GP1) - the module only answers while its L/N side has mains
231.4 V  0.412 A  84.6 W  PF 0.89  50.0 Hz  total 12.345 kWh
230.9 V  1.873 A  421.2 W  PF 0.97  50.0 Hz  total 12.346 kWh

Common errors

No answer - mains off on L/N…

The measuring side is powered from the mains it measures, so with no 230 V on L/N it never replies. Also check TX/RX are crossed and the divider is in place.

Voltage OK, current and power 0

The CT is around both conductors or not closed properly. An electrician should clamp it around the live wire alone.

kWh differs from the electricity meter

Expect about 1 % difference from the DISCOM meter. The energy count lives in the PZEM and survives power cuts.

↑↓ to move · Enter to open