Tutorials

Modbus to MQTT Gateway for PLCs: A Python Modbus TCP Poller

Build a Modbus to MQTT gateway in Python: poll PLC registers over Modbus TCP with pymodbus, decode them, and publish clean JSON to MQTT topics you can test.

Updated · 9 min read

Modbus to MQTT Gateway for PLCs: A Python Modbus TCP Poller

A Modbus to MQTT gateway reads registers from a PLC over Modbus TCP and publishes them as MQTT messages, so dashboards, historians and cloud services can subscribe to plant data without ever touching the PLC. You can build a reliable one in about 150 lines of Python with pymodbus and paho-mqtt.

This tutorial builds that gateway step by step: a tag map, a polling loop, decoding of 32-bit values, report-by-exception publishing, online/offline status with a Last Will, and a way to test everything before it goes near a production line.

How the gateway fits in

Modbus is a request/response protocol: a master (client) asks a slave (server) for registers, one request at a time. MQTT is publish/subscribe: producers publish once, any number of consumers subscribe. The gateway translates between the two worlds:

SideProtocolRole of the gateway
PLC networkModbus TCP, port 502Modbus client that polls holding/input registers and coils
Plant / IT networkMQTT over TCP or TLSMQTT client that publishes one topic per tag

Run the gateway on an edge box (industrial PC, Raspberry Pi, a container on the line server) that can reach both networks. The PLC never sees MQTT traffic and the MQTT consumers never open a Modbus socket — which is exactly the separation OT teams want. For the bigger picture, see MQTT for IIoT and Industry 4.0.

1. Describe your tags

Modbus has no names, only addresses. The first job is a tag map that says what each register means. Keep it in a file next to the code so it can be reviewed like configuration:

tags.py
# name, function, address, data type, scale, unit
TAGS = [
    ("temperature",  "holding", 0,  "float32", 1.0,  "degC"),
    ("pressure",     "holding", 2,  "float32", 1.0,  "bar"),
    ("motor_speed",  "holding", 4,  "uint16",  1.0,  "rpm"),
    ("part_count",   "holding", 5,  "uint32",  1.0,  "pcs"),
    ("tank_level",   "input",   0,  "int16",   0.1,  "%"),
    ("running",      "coil",    0,  "bool",    1,    ""),
]

Addresses here are zero-based protocol addresses. Vendor documentation often uses one-based "40001" style numbering: holding register 40001 is address 0, 40003 is address 2. Off-by-one is the most common Modbus bug, so check one known value before trusting the rest.

2. Poll and decode registers

A Modbus register is 16 bits. Floats and 32-bit counters span two registers, and vendors disagree on which word comes first. Decoding with struct keeps the word order explicit:

modbus_read.py
import struct
from pymodbus.client import ModbusTcpClient

WORD_ORDER_BIG = True   # set False for "little-endian word swap" devices

def decode(regs, dtype):
    if dtype == "uint16":
        return regs[0]
    if dtype == "int16":
        return struct.unpack(">h", struct.pack(">H", regs[0]))[0]
    words = regs if WORD_ORDER_BIG else list(reversed(regs))
    raw = struct.pack(">HH", *words)
    return struct.unpack(">f" if dtype == "float32" else ">I", raw)[0]

def read_tag(client, unit, func, addr, dtype):
    count = 2 if dtype in ("float32", "uint32") else 1
    if func == "coil":
        rr = client.read_coils(addr, count=1, device_id=unit)
        return None if rr.isError() else bool(rr.bits[0])
    reader = client.read_holding_registers if func == "holding" else client.read_input_registers
    rr = reader(addr, count=count, device_id=unit)
    return None if rr.isError() else decode(rr.registers, dtype)

In production you should read contiguous blocks (for example registers 0–5 in one request) instead of one request per tag: a PLC answers one larger request faster than six small ones. The per-tag version above is easier to read and fine for a few dozen tags.

3. Publish to MQTT with report by exception

Now combine the poller with an MQTT client. The gateway publishes one topic per tag, sends a value only when it changes beyond a deadband, marks values as retained so new subscribers get the current state immediately, and uses a Last Will to announce when it goes offline.

gateway.py
import json, os, time
import paho.mqtt.client as mqtt
from pymodbus.client import ModbusTcpClient
from modbus_read import read_tag
from tags import TAGS

PLC_HOST, PLC_UNIT = os.getenv("PLC_HOST", "192.168.10.20"), 1
BASE = "testmqtt/acme/plant1/line2/plc1"
POLL_S, DEADBAND = 1.0, 0.05            # seconds, relative change (5 %)

mq = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="gw-line2-plc1")
mq.username_pw_set(os.getenv("MQTT_USER"), os.getenv("MQTT_PASS"))
mq.tls_set()
mq.will_set(f"{BASE}/status", "offline", qos=1, retain=True)

def on_connect(client, userdata, flags, reason_code, properties):
    print("MQTT connected:", reason_code)
    client.publish(f"{BASE}/status", "online", qos=1, retain=True)

mq.on_connect = on_connect
mq.connect("broker.connectmq.com", 8883, keepalive=30)
mq.loop_start()

plc = ModbusTcpClient(PLC_HOST, port=502, timeout=2)
last = {}

def changed(name, value):
    old = last.get(name)
    if old is None or isinstance(value, bool):
        return old != value
    return abs(value - old) > abs(old) * DEADBAND

while True:
    if not plc.connected and not plc.connect():
        mq.publish(f"{BASE}/status", "plc-unreachable", qos=1, retain=True)
        time.sleep(5)
        continue
    for name, func, addr, dtype, scale, unit in TAGS:
        value = read_tag(plc, PLC_UNIT, func, addr, dtype)
        if value is None:
            continue
        if not isinstance(value, bool):
            value = round(value * scale, 3)
        if changed(name, value):
            last[name] = value
            payload = {"v": value, "unit": unit, "ts": int(time.time() * 1000)}
            mq.publish(f"{BASE}/{name}", json.dumps(payload), qos=1, retain=True)
    time.sleep(POLL_S)

A few decisions worth explaining:

  • One topic per tag (.../plc1/temperature) lets consumers subscribe to exactly what they need, and wildcards such as .../plc1/+ still give them everything. Test patterns with the topic matcher.
  • Retained + QoS 1 means a dashboard opened at 3 a.m. shows the last known value instantly, and messages survive short network hiccups. See retained messages.
  • Status topic with a Last Will distinguishes "temperature is stable" from "the gateway died". The broker publishes offline on your behalf if the gateway's connection drops without a clean disconnect.
  • Timestamps in the payload record when the value was read, not when it arrived — important for historians.

The topic layout follows an ISA-95 style hierarchy (enterprise / site / line / device), which is the starting point for a Unified Namespace.

4. Test it without a production PLC

Never develop against a running line. Start with a Modbus simulator — pymodbus ships one, and tools such as diagslave or ModbusPal serve configurable registers on port 502 (or 5020 if you are not root). Point PLC_HOST at it and run the gateway.

Then check the MQTT side:

  1. Open the online MQTT client, connect to broker.connectmq.com and subscribe to testmqtt/acme/plant1/#. You should see the retained status = online and one message per tag right away.
  2. Change a register in the simulator. Only that tag should publish a new message; unchanged tags stay silent.
  3. Stop the gateway with kill -9 (no clean disconnect). After roughly one and a half times the keep-alive, status should flip to offline.
  4. Stop the simulator. status should report plc-unreachable while MQTT stays connected.

Watch the gateway topics in your browser

From a terminal the same check is one command:

Watch every tag from line 2
mosquitto_sub -h broker.connectmq.com -p 8883 --capath /etc/ssl/certs \
  -u "$MQTT_USER" -P "$MQTT_PASS" \
  -t 'testmqtt/acme/plant1/line2/#' -v

Production hardening

ConcernWhat to do
Gateway restartsRun it under systemd or Docker with restart: always; the retained status and values recover automatically
PLC loadBatch contiguous registers, poll no faster than needed, use one connection per PLC
Many PLCsOne thread or asyncio task per PLC, each with its own status topic
Broker outagepaho reconnects automatically after loop_start(); buffer readings locally if you cannot afford gaps
Writes to the PLCKeep them off by default; if needed, subscribe to a dedicated .../cmd topic, allow-list registers and validate ranges
SecuritySeparate OT and IT networks, never expose port 502 to the internet, use TLS and per-gateway credentials for MQTT

Modbus has no authentication at all, so the gateway is often the only controlled boundary between the PLC and everything else. Keep its MQTT permissions narrow and its write path closed unless you truly need it.

If your sites later move to Sparkplug B or need standard state handling for many devices, the same polling core can feed a Sparkplug B edge node instead of plain JSON topics.

A Modbus to MQTT gateway is a small piece of code with a big effect: it turns registers only the PLC programmer understands into named, timestamped, subscribable data. Start with a clear tag map, publish on change with retained values and a Last Will, test against a simulator and the online client, and only then connect it to the line.

Frequently asked questions

Can a PLC publish to MQTT directly instead of using a Modbus gateway?

Many newer PLCs and edge controllers have a built-in MQTT client, and then you should use it. A Modbus to MQTT gateway is the right tool for the large installed base of PLCs, meters and drives that only speak Modbus TCP or RTU.

How often should I poll Modbus registers?

As slowly as the process allows. One to five seconds is common for temperatures and counters; fast signals may need 100–250 ms. Poll fast if needed, but publish only on change (with a deadband) so MQTT traffic stays small.

Should the gateway write values back to the PLC from MQTT?

Only deliberately. Restrict writes to an explicit allow-list of registers, validate ranges, protect the command topic with per-topic ACLs and log every write. Many plants keep the gateway read-only.