A raspberry pi modbus mqtt gateway lets you take readings from energy meters, inverters, VFDs and PLCs that only speak Modbus and publish them as clean JSON over MQTT. In this tutorial you will wire a USB RS-485 adapter for Modbus RTU, connect to Modbus TCP devices over Ethernet, describe each device's registers in a small config, decode scaled integers and 32-bit floats correctly and publish the results to a broker. The final script handles timeouts and reconnects, so it can run unattended.
Modbus in Two Minutes
Modbus is a request and response protocol: the Pi (the client, historically called master) asks a device (the server, historically slave) for a block of registers, and the device answers. It has no notion of topics, subscriptions or push; everything is polling. That is exactly why a gateway is needed to feed MQTT systems.
| Concept | Modbus RTU | Modbus TCP |
|---|---|---|
| Physical layer | RS-485 two-wire bus | Ethernet |
| Addressing | Unit ID 1 to 247 on the bus | IP address plus unit ID |
| Typical speed | 9600 or 19200 baud | Network speed |
| Pi hardware | USB RS-485 adapter or HAT | Built-in Ethernet |
| Common pitfall | Wrong parity or missing termination | Wrong unit ID behind a gateway |
Data lives in four tables: coils and discrete inputs (bits), and input and holding registers (16-bit words). Most meters expose measurements as holding or input registers. Values that do not fit in 16 bits, such as energy totals or floating-point voltages, span two consecutive registers, and the device manual tells you the word order. Getting that order wrong is the most common reason a gateway publishes absurd numbers.
For deeper protocol background on each variant, see the guides on Modbus RTU to MQTT and Modbus TCP to MQTT.
Hardware and Wiring for Modbus RTU
- Plug a USB RS-485 adapter into the Pi. It appears as
/dev/ttyUSB0. Usels /dev/serial/by-id/to get a stable name that survives reboots and replugging. - Connect A to A (sometimes labelled D+) and B to B (D-). If you see no responses at all, swapping A and B is the first thing to try, because vendors disagree on labelling.
- Connect the signal ground if the adapter and device provide one. Long runs without a common reference cause intermittent errors.
- Terminate the two physical ends of the bus with 120 ohm resistors on longer runs. Many devices and adapters have a termination switch.
- Note the device's baud rate, parity, stop bits and unit ID from its display or manual. Every device on one bus must share the same serial settings.
For Modbus TCP you only need the device's IP address, port (usually 502) and unit ID. Behind a Modbus TCP-to-RTU gateway, the unit ID selects the RTU device on the far side.
Install pymodbus and paho-mqtt
sudo apt update && sudo apt install -y python3-venv
python3 -m venv ~/modbus-env
~/modbus-env/bin/pip install "pymodbus>=3.10" pyserial "paho-mqtt>=2.0"
sudo usermod -aG dialout $USER # serial access, then log out and in
pymodbus 3.10 renamed the unit argument from slave to device_id. The code below uses device_id; if you are stuck on an older 3.x release, change it back to slave.
Quick sanity check from a Python shell before writing anything bigger:
from pymodbus.client import ModbusSerialClient
c = ModbusSerialClient(port="/dev/ttyUSB0", baudrate=9600, parity="N",
stopbits=1, bytesize=8, timeout=1)
print(c.connect())
rr = c.read_holding_registers(0, count=2, device_id=1)
print(rr if rr.isError() else rr.registers)
c.close()
If this prints register values, wiring and settings are right. If it prints a timeout, check A/B, baud rate, parity and unit ID in that order.
Describe Devices in a Register Map
Hard-coding addresses in Python gets messy fast. Put each device's registers into a JSON file instead. Save as ~/devices.json:
{
"devices": [
{
"name": "meter1",
"area": "utilities",
"transport": "rtu",
"port": "/dev/ttyUSB0",
"baudrate": 9600,
"parity": "N",
"unit": 1,
"interval": 10,
"registers": [
{"field": "voltage_v", "table": "holding", "address": 0, "type": "uint16", "scale": 0.1},
{"field": "current_a", "table": "holding", "address": 1, "type": "uint16", "scale": 0.01},
{"field": "power_w", "table": "holding", "address": 2, "type": "int32", "word_order": "big"},
{"field": "energy_kwh", "table": "input", "address": 10, "type": "float32", "word_order": "little"}
]
},
{
"name": "vfd1",
"area": "line1",
"transport": "tcp",
"host": "192.168.1.70",
"port": 502,
"unit": 1,
"interval": 5,
"registers": [
{"field": "speed_rpm", "table": "holding", "address": 100, "type": "uint16", "scale": 1},
{"field": "fault_code", "table": "holding", "address": 105, "type": "uint16", "scale": 1}
]
}
]
}
The addresses above are examples. Use the register table from your own device's manual, and watch for manuals that list addresses starting at 1 or with a 40001 offset: pymodbus expects the zero-based protocol address, so register 40001 is address 0.
The Raspberry Pi Modbus MQTT Gateway Script
Save as ~/modbus2mqtt.py:
#!/usr/bin/env python3
"""Poll Modbus RTU/TCP devices and publish readings over MQTT."""
import json
import os
import ssl
import struct
import time
import paho.mqtt.client as mqtt
from pymodbus.client import ModbusSerialClient, ModbusTcpClient
from pymodbus.exceptions import ModbusException
HOST = os.getenv("MQTT_HOST", "broker.connectmq.com")
PORT = int(os.getenv("MQTT_PORT", "1883"))
USERNAME = os.getenv("MQTT_USERNAME") # YOUR_USERNAME
PASSWORD = os.getenv("MQTT_PASSWORD") # YOUR_PASSWORD
CLIENT_ID = os.getenv("MQTT_CLIENT_ID", "modbus-gw-2f84ad") # YOUR_CLIENT_ID
PREFIX = os.getenv("MQTT_PREFIX", "connectmq/demo/2f84ad/site1")
CONFIG = os.getenv("DEVICES", os.path.expanduser("~/devices.json"))
SIZES = {"uint16": 1, "int16": 1, "uint32": 2, "int32": 2, "float32": 2}
def decode(regs, rtype, word_order="big"):
if SIZES[rtype] == 2 and word_order == "little":
regs = [regs[1], regs[0]]
raw = b"".join(struct.pack(">H", r) for r in regs)
fmt = {"uint16": ">H", "int16": ">h", "uint32": ">I",
"int32": ">i", "float32": ">f"}[rtype]
return struct.unpack(fmt, raw)[0]
def make_client(dev):
if dev["transport"] == "rtu":
return ModbusSerialClient(port=dev["port"], baudrate=dev.get("baudrate", 9600),
parity=dev.get("parity", "N"), stopbits=dev.get("stopbits", 1),
bytesize=8, timeout=1, retries=1)
return ModbusTcpClient(dev["host"], port=dev.get("port", 502), timeout=2, retries=1)
def read_device(mb, dev):
values = {}
for reg in dev["registers"]:
count = SIZES[reg["type"]]
fn = mb.read_holding_registers if reg["table"] == "holding" else mb.read_input_registers
rr = fn(reg["address"], count=count, device_id=dev["unit"])
if rr.isError():
raise ModbusException(f"{dev['name']} {reg['field']}: {rr}")
v = decode(rr.registers, reg["type"], reg.get("word_order", "big"))
v = v * reg.get("scale", 1)
values[reg["field"]] = round(v, 4) if isinstance(v, float) else v
return values
# ---------- MQTT ----------
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=CLIENT_ID,
protocol=mqtt.MQTTv5)
if USERNAME:
client.username_pw_set(USERNAME, PASSWORD)
if PORT == 8883:
client.tls_set(cert_reqs=ssl.CERT_REQUIRED)
gw_status = f"{PREFIX}/gateway/modbus/status"
client.will_set(gw_status, "offline", qos=1, retain=True)
def on_connect(c, userdata, flags, reason_code, properties):
if not reason_code.is_failure:
c.publish(gw_status, "online", qos=1, retain=True)
client.on_connect = on_connect
client.reconnect_delay_set(1, 60)
client.connect_async(HOST, PORT, keepalive=60)
client.loop_start()
# ---------- polling loop ----------
with open(CONFIG) as f:
devices = json.load(f)["devices"]
clients = {}
next_poll = {d["name"]: 0.0 for d in devices}
last_ok = {}
while True:
now = time.monotonic()
for dev in devices:
name = dev["name"]
if now < next_poll[name]:
continue
next_poll[name] = now + dev.get("interval", 10)
base = f"{PREFIX}/{dev['area']}/{name}"
# RTU devices on the same port share one client
key = dev.get("port") if dev["transport"] == "rtu" else f"{dev['host']}:{dev.get('port', 502)}"
mb = clients.get(key) or clients.setdefault(key, make_client(dev))
try:
if not mb.connected:
mb.connect()
values = read_device(mb, dev)
payload = {"ts": int(time.time()), "values": values}
client.publish(f"{base}/telemetry", json.dumps(payload), qos=1)
if last_ok.get(name) is not True:
client.publish(f"{base}/status", "online", qos=1, retain=True)
last_ok[name] = True
except (ModbusException, OSError) as exc:
print(f"{name}: {exc}")
if last_ok.get(name) is not False:
client.publish(f"{base}/status", "offline", qos=1, retain=True)
last_ok[name] = False
mb.close()
time.sleep(0.2)
Design notes worth understanding:
- One client per physical connection. RTU devices on the same RS-485 port share a single serial client, because two processes or objects opening the same port corrupt each other's frames.
- Per-device status topics. Each device gets a retained
onlineoroffline, published only when the state changes. Dashboards immediately see which meter has stopped answering, separate from the gateway's own status. - Raw registers are decoded in one place. The
decodefunction handles 16- and 32-bit types and word order withstruct, so it does not depend on helper APIs that have changed between pymodbus versions. - Polling intervals per device. A VFD speed every 5 seconds and an energy total every 60 is a sensible mix; do not poll faster than your consumers need.
Test the Raspberry Pi Modbus MQTT Output
Run it in the foreground:
~/modbus-env/bin/python ~/modbus2mqtt.py
Watch the output on the free public MQTT broker from another terminal:
mosquitto_sub -h broker.connectmq.com -p 1883 -i watch-2f84ad \
-t 'connectmq/demo/2f84ad/#' -v
Expected output:
connectmq/demo/2f84ad/site1/gateway/modbus/status online
connectmq/demo/2f84ad/site1/utilities/meter1/status online
connectmq/demo/2f84ad/site1/utilities/meter1/telemetry {"ts": 1760601234, "values": {"voltage_v": 231.4, "current_a": 1.27, "power_w": 287, "energy_kwh": 1532.6875}}
Compare at least one value with the device's own display. If a float is wildly wrong (huge exponents or tiny fractions), flip word_order. If a scaled value is off by a factor of ten, fix scale.
No Modbus hardware yet? pymodbus includes a simulator, and the Modbus TCP to MQTT guide shows how to run one on a laptop for testing.
Production: Private Broker, TLS and a Service
Meter and machine data is operational information, so production gateways should publish to a private broker with encryption. With ConnectMQ, a fully managed MQTT service, you create a broker in the portal and receive a username, password and client ID. Put them into /etc/modbus2mqtt.env:
MQTT_HOST=broker.connectmq.com
MQTT_PORT=8883
MQTT_USERNAME=YOUR_USERNAME
MQTT_PASSWORD=YOUR_PASSWORD
MQTT_CLIENT_ID=YOUR_CLIENT_ID
MQTT_PREFIX=acme/plant1
DEVICES=/home/pi/devices.json
Then a systemd unit with EnvironmentFile=/etc/modbus2mqtt.env, Restart=always and After=network-online.target, as in the other Pi tutorials in this series. If you structure the prefix as enterprise, site, area and line, the ConnectMQ portal recognises the ISA-95 hierarchy and groups the meters in its devices view, and threshold alert rules can flag values such as a VFD fault_code that is not zero.
Two more production considerations. First, if the uplink may be down for long periods, add a disk buffer in front of the publish call; the Raspberry Pi IoT gateway guide includes a SQLite outbox you can reuse. Second, if you plan to write to devices from the cloud, add strict validation and an allow-list of writable registers. A wrong Modbus write can stop a machine.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Every read times out (RTU) | A/B swapped, wrong baud or parity | Swap A/B, match serial settings |
| Some reads time out on a long bus | No termination or no common ground | Add 120 ohm termination, connect ground |
Illegal data address exception |
Address offset or wrong table | Subtract 40001 or 30001, check holding vs input |
| Float values absurd | Wrong word order | Toggle word_order |
Permission denied: /dev/ttyUSB0 |
User not in dialout |
usermod -aG dialout, log in again |
TypeError on device_id |
Old pymodbus | Upgrade to 3.10+ or use slave |
Get Started with ConnectMQ
Send your Modbus data somewhere useful: create a broker at app.connectmq.com on the free Serverless plan, no credit card required, and watch your meter readings arrive in the portal's live topic explorer. For the rest of the Pi series, start at the Raspberry Pi MQTT tutorial.
Frequently Asked Questions
Can one Raspberry Pi read several Modbus RTU devices?
Yes. Put them on the same RS-485 bus with unique unit IDs and identical serial settings, and the gateway polls them one after another through a single serial client.
What is the difference between holding and input registers?
Input registers are read-only measurements, while holding registers can be read and often written, for settings and setpoints. Your device manual says which table each value lives in.
How fast can the gateway poll Modbus RTU?
It depends on baud rate, the number of registers and device response time. Group consecutive registers into one read where possible and poll only as often as your consumers need.
Should the gateway publish one message per register or one per device?
One JSON message per device and poll is usually best. It keeps related values together with one timestamp and uses far fewer messages.



