Raspberry Pi

Raspberry Pi Modbus MQTT Gateway with pymodbus (RTU and TCP)

Poll Modbus RTU devices over RS-485 and Modbus TCP devices over Ethernet from a Raspberry Pi with pymodbus, decode scaled and 32-bit float registers correctly, and publish clean JSON to an MQTT broker.

Raspberry Pi with a USB RS-485 adapter wired to an energy meter as a raspberry pi modbus mqtt gateway to the cloud
Run this on your own MQTT brokerGet a private, managed MQTT broker on the Free plan and start from ready-made code samples.
Try it free Code samples No card required

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

  1. Plug a USB RS-485 adapter into the Pi. It appears as /dev/ttyUSB0. Use ls /dev/serial/by-id/ to get a stable name that survives reboots and replugging.
  2. 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.
  3. Connect the signal ground if the adapter and device provide one. Long runs without a common reference cause intermittent errors.
  4. Terminate the two physical ends of the bus with 120 ohm resistors on longer runs. Many devices and adapters have a termination switch.
  5. 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 online or offline, 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 decode function handles 16- and 32-bit types and word order with struct, 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.

Related articles

Pico W vs Pico 2 W for MQTT: RP2040 vs RP2350 Performance, Memory and TLS

The Pico 2 W keeps the Pico W's footprint and Wi-Fi chip but swaps the RP2040 for the faster RP2350. This comparison looks at what that means for MQTT devices: CPU, memory, TLS, security features, software support, and which board to buy for which project.

8 min read

Raspberry Pi RFID MQTT Access Logging with the RC522 Reader

Wire an RC522 RFID reader to a Raspberry Pi, read tags in Python, check them against a local allowlist and publish every access attempt as an MQTT event that loggers, dashboards and alerting tools can consume.

7 min read

All articles →

Your private MQTT broker is a few clicks away

Create an account, get your broker credentials and connect your first device — no servers to install, patch or scale.