When tackling networking raspberry pi projects, the jump from reading a local I2C sensor to publishing industrial-grade Modbus data over MQTT is a major milestone. This build bridges the gap between high-voltage AC monitoring and modern IoT dashboards. We are building a networked AC power monitor using a Raspberry Pi 5, a PZEM-004T v3 Modbus RTU sensor, and an RS485 transceiver, publishing live voltage, current, and power data to an MQTT broker.

This guide targets the Raspberry Pi 5 (8GB variant) running Raspberry Pi OS Bookworm (64-bit). The Pi 5’s new RP1 southbridge chip changes how UART peripherals are mapped compared to the Pi 4, making exact pin configuration critical. By the end, you will have a headless, auto-starting Python script pushing JSON payloads to your home automation or industrial SCADA system.

Project Specs and Parts List

Difficulty Rating: Intermediate (Requires mains voltage safety awareness and Linux serial config)
Estimated Time: 90 minutes
Total Cost: ~$105 USD (2026 pricing)
  • Compute: Raspberry Pi 5 (8GB) with active cooler and 27W USB-C power supply ($80)
  • Sensor: PZEM-004T v3 (Modbus RTU version, not the older v1 TTL version) with split-core CT clamp ($18)
  • Transceiver: HW-519 Auto-Direction TTL to RS485 Module (3.3V compatible) ($3)
  • Wiring: 22 AWG stranded hookup wire, 120-ohm terminating resistor (for RS485 runs over 10 meters) ($4)
⚠️ Mains Voltage Warning: The PZEM-004T terminal blocks connect directly to 120V/240V AC line voltage. De-energize your panel, lock out the breaker, and verify dead with a CAT III multimeter before terminating the AC wires. Never touch the sensor board while the mains are live.

Hardware Wiring and Pin Mapping

The Raspberry Pi 5 routes its primary UART through the RP1 chip to GPIO 14 (TX) and GPIO 15 (RX), exposed as /dev/ttyAMA0. We use an auto-direction RS485 module to handle the half-duplex DE/RE pin toggling in hardware, saving us from writing software RTS delays in Python.

Table 1: Raspberry Pi 5 GPIO to HW-519 RS485 Module Pinout
Pi 5 Pin (Physical) BCM GPIO Function HW-519 RS485 Pin
Pin 1 3.3V Power VCC VCC
Pin 6 Ground GND GND
Pin 8 GPIO 14 (TXD) UART Transmit TXD
Pin 10 GPIO 15 (RXD) UART Receive RXD

Note: Connect the HW-519's A+ and B- terminals to the PZEM-004T v3's A+ and B- terminals. If your cable run exceeds 10 meters, solder a 120-ohm resistor across the A+ and B- lines at the PZEM end to prevent signal reflection.

The PZEM-004T Modbus Register Map

Before writing code, you need to know exactly where the data lives. The PZEM-004T v3 communicates via Modbus RTU at 9600 baud (8-N-1). It uses Function Code 03 (Read Holding Registers). Below is the data-dense register map you will reference when parsing the payload.

Table 2: PZEM-004T v3 Modbus Holding Registers (Slave ID 0x01)
Parameter Start Address (Hex) Register Count Data Type Resolution / Scale
Voltage 0x0000 1 16-bit Unsigned Int 0.1 V
Current 0x0001 2 32-bit Unsigned Int 0.001 A
Active Power 0x0003 2 32-bit Unsigned Int 0.1 W
Active Energy 0x0005 2 32-bit Unsigned Int 1 Wh
Frequency 0x0007 1 16-bit Unsigned Int 0.1 Hz
Power Factor 0x0008 1 16-bit Unsigned Int 0.01

Python MQTT Publisher Code

This script uses pymodbus to poll the sensor and paho-mqtt (v2.0+) to publish the JSON payload. Install the dependencies first:

pip install pymodbus==3.6.4 paho-mqtt==2.0.0

Save the following as pi5_power_monitor.py. The code includes explicit pin definitions, Paho v2.0 callback signatures, and robust error handling for serial dropouts.

import time
import json
import logging
from pymodbus.client import ModbusSerialClient
from pymodbus.exceptions import ModbusIOException
import paho.mqtt.client as mqtt

# --- Configuration & Pin Definitions ---
# Target Board: Raspberry Pi 5 (ttyAMA0 mapped to GPIO 14/15 via RP1)
SERIAL_PORT = '/dev/ttyAMA0'
BAUD_RATE = 9600
MODBUS_SLAVE_ID = 1

MQTT_BROKER = '192.168.1.50'
MQTT_PORT = 1883
MQTT_TOPIC = 'home/laboratory/power/panel_A'

# Modbus Register Map (Start Address, Count, Scale Factor)
REGISTERS = {
    'voltage':      {'addr': 0x0000, 'count': 1, 'scale': 0.1},
    'current':      {'addr': 0x0001, 'count': 2, 'scale': 0.001},
    'power':        {'addr': 0x0003, 'count': 2, 'scale': 0.1},
    'energy':       {'addr': 0x0005, 'count': 2, 'scale': 1.0},
    'frequency':    {'addr': 0x0007, 'count': 1, 'scale': 0.1},
    'power_factor': {'addr': 0x0008, 'count': 1, 'scale': 0.01}
}

logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')

# --- MQTT Callbacks (Paho v2.0 API) ---
def on_connect(client, userdata, flags, reason_code, properties):
    if reason_code == 0:
        logging.info("Successfully connected to MQTT Broker")
    else:
        logging.error(f"MQTT Connection failed with reason code: {reason_code}")

def on_publish(client, userdata, mid, reason_code, properties):
    pass # Silent success for high-frequency polling

# --- Modbus Polling Logic ---
def read_pzem_data(client):
    payload = {}
    for param, config in REGISTERS.items():
        try:
            result = client.read_holding_registers(
                address=config['addr'], 
                count=config['count'], 
                slave=MODBUS_SLAVE_ID
            )
            if result.isError():
                raise ModbusIOException(f"Error reading {param}")
            
            # Handle 16-bit vs 32-bit register parsing
            if config['count'] == 2:
                # PZEM uses Little-Endian word order for 32-bit values
                raw_val = (result.registers[1] << 16) | result.registers[0]
            else:
                raw_val = result.registers[0]
                
            payload[param] = round(raw_val * config['scale'], 3)
        except ModbusIOException as e:
            logging.warning(f"Modbus timeout on {param}: {e}")
            payload[param] = None
    return payload

# --- Main Execution Loop ---
if __name__ == "__main__":
    # Initialize Modbus Serial Client
    modbus_client = ModbusSerialClient(
        port=SERIAL_PORT, baudrate=BAUD_RATE, bytesize=8, parity='N', stopbits=1, timeout=1
    )
    
    if not modbus_client.connect():
        logging.critical(f"Failed to open serial port {SERIAL_PORT}. Check permissions and raspi-config.")
        exit(1)

    # Initialize MQTT Client
    mqtt_client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
    mqtt_client.on_connect = on_connect
    mqtt_client.on_publish = on_publish
    
    try:
        mqtt_client.connect(MQTT_BROKER, MQTT_PORT, 60)
        mqtt_client.loop_start()
    except ConnectionRefusedError:
        logging.critical(f"MQTT Broker at {MQTT_BROKER} refused connection.")
        modbus_client.close()
        exit(1)

    logging.info("Starting polling loop...")
    try:
        while True:
            data = read_pzem_data(modbus_client)
            if any(v is not None for v in data.values()):
                json_payload = json.dumps(data)
                mqtt_client.publish(MQTT_TOPIC, json_payload, qos=1)
                logging.info(f"Published: {json_payload}")
            time.sleep(5)
    except KeyboardInterrupt:
        logging.info("Shutting down gracefully...")
    finally:
        mqtt_client.loop_stop()
        mqtt_client.disconnect()
        modbus_client.close()

Debugging: First Three Things to Check When It Fails

Serial communications on the Pi 5 are notoriously unforgiving if the OS configuration isn't exact. If your script crashes or returns nulls, check these three specific failure modes in order.

1. The Permission Denied / Port Not Found Error

Exact Error String: serial.serialutil.SerialException: [Errno 13] Permission denied: '/dev/ttyAMA0' or [Errno 2] No such file or directory

Ranked Causes & Fixes:

  1. Serial Console is Hijacking the Port: By default, Pi OS routes the boot console to ttyAMA0. Run sudo raspi-config -> Interface Options -> Serial Port. Select No for "login shell to be accessible over serial" and Yes for "serial port hardware to be enabled". Reboot.
  2. Missing Dialout Group: Your user lacks hardware access. Fix with: sudo usermod -a -G dialout $USER, then log out and back in.
  3. Wrong Device Tree Overlay: If /dev/ttyAMA0 doesn't exist, add dtoverlay=disable-bt to /boot/firmware/config.txt to free the primary UART from the Bluetooth module.

2. The Modbus Timeout Error

Exact Error String: pymodbus.exceptions.ModbusIOException: Modbus Error: [Input/Output] Modbus Error: [Invalid Message] No response received, expected at least 8 bytes (0 received)

Ranked Causes & Fixes:

  1. TX/RX Crossed Incorrectly: RS485 modules label pins from the module's perspective. Pi TX must go to Module TX (which routes to the RS485 driver input). If using a non-auto-direction module, ensure DE and RE are pulled HIGH and LOW respectively during transmit.
  2. Baud Rate Mismatch: The PZEM-004T v3 is hardcoded to 9600 baud at the factory. If you previously used a configuration tool to change it to 115200, you must update the BAUD_RATE variable in the Python script.
  3. Missing Common Ground: RS485 is differential, but the transceivers still need a shared ground reference to keep the common-mode voltage within the receiver's limits. Ensure Pi GND is connected to the PZEM GND terminal.

3. The MQTT Connection Refused Error

Exact Error String: ConnectionRefusedError: [Errno 111] Connection refused

Ranked Causes & Fixes:

  1. Mosquitto Listener Binding: Modern Mosquitto (v2.0+) defaults to localhost-only. Edit /etc/mosquitto/mosquitto.conf on your broker machine and add listener 1883 and allow_anonymous true (or configure ACLs), then restart the service.
  2. Firewall Blocking 1883: If the broker is on a separate Ubuntu/Debian server, run sudo ufw allow 1883/tcp.

Extending and Simplifying the Build

Depending on your deployment environment, you may need to scale this architecture up or strip it down for reliability.

How to Simplify (The USB Bypass)

If you are struggling with the Pi 5’s RP1 UART mapping or need to deploy this in an electrically noisy panel where GPIO ground loops are a risk, ditch the GPIO UART entirely. Purchase an FTDI-based USB-to-RS485 adapter (like the DSD TECH SH-U09C, ~$15). Plug it into the Pi 5’s USB 3.0 port, change SERIAL_PORT in the code to /dev/ttyUSB0, and bypass raspi-config serial settings completely. This adds galvanic isolation and eliminates OS-level UART conflicts.

How to Extend (Multi-Drop Daisy Chaining)

RS485 supports up to 32 devices on a single bus. To monitor three separate breaker panels:

  1. Wire the A+ and B- terminals of all three PZEM-004T modules in parallel (daisy-chain the twisted pair).
  2. Use the PZEM-004T's physical push-button or a Modbus write command (FC 06, Address 0x0002) to change the Slave ID of the second module to 0x02 and the third to 0x03.
  3. Modify the Python script to loop through a list of MODBUS_SLAVE_IDS = [1, 2, 3], appending the ID to the MQTT topic (e.g., home/power/panel_1).

For deeper integration, reference the official Raspberry Pi UART configuration documentation to manage device tree overlays, and consult the PyModbus library documentation for advanced timeout tuning in high-latency RS485 networks. If you are migrating older MQTT scripts, review the Paho MQTT v2.0 migration guide to ensure your callback signatures match the current API standard.