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
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)
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.
| 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.
| 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:
- Serial Console is Hijacking the Port: By default, Pi OS routes the boot console to
ttyAMA0. Runsudo 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. - Missing Dialout Group: Your user lacks hardware access. Fix with:
sudo usermod -a -G dialout $USER, then log out and back in. - Wrong Device Tree Overlay: If
/dev/ttyAMA0doesn't exist, adddtoverlay=disable-btto/boot/firmware/config.txtto 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:
- 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.
- 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_RATEvariable in the Python script. - 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:
- Mosquitto Listener Binding: Modern Mosquitto (v2.0+) defaults to localhost-only. Edit
/etc/mosquitto/mosquitto.confon your broker machine and addlistener 1883andallow_anonymous true(or configure ACLs), then restart the service. - 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:
- Wire the A+ and B- terminals of all three PZEM-004T modules in parallel (daisy-chain the twisted pair).
- 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 to0x02and the third to0x03. - 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.






