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:
| Side | Protocol | Role of the gateway |
|---|---|---|
| PLC network | Modbus TCP, port 502 | Modbus client that polls holding/input registers and coils |
| Plant / IT network | MQTT over TCP or TLS | MQTT 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:
# 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:
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.
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
offlineon 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:
- Open the online MQTT client, connect to
broker.connectmq.comand subscribe totestmqtt/acme/plant1/#. You should see the retainedstatus=onlineand one message per tag right away. - Change a register in the simulator. Only that tag should publish a new message; unchanged tags stay silent.
- Stop the gateway with
kill -9(no clean disconnect). After roughly one and a half times the keep-alive,statusshould flip tooffline. - Stop the simulator.
statusshould reportplc-unreachablewhile MQTT stays connected.
Watch the gateway topics in your browser
From a terminal the same check is one command:
mosquitto_sub -h broker.connectmq.com -p 8883 --capath /etc/ssl/certs \
-u "$MQTT_USER" -P "$MQTT_PASS" \
-t 'testmqtt/acme/plant1/line2/#' -vProduction hardening
| Concern | What to do |
|---|---|
| Gateway restarts | Run it under systemd or Docker with restart: always; the retained status and values recover automatically |
| PLC load | Batch contiguous registers, poll no faster than needed, use one connection per PLC |
| Many PLCs | One thread or asyncio task per PLC, each with its own status topic |
| Broker outage | paho reconnects automatically after loop_start(); buffer readings locally if you cannot afford gaps |
| Writes to the PLC | Keep them off by default; if needed, subscribe to a dedicated .../cmd topic, allow-list registers and validate ranges |
| Security | Separate 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.


