Modbus TCP to MQTT is the most common bridge in industrial IoT, because Modbus is everywhere and MQTT is how that data reaches dashboards and the cloud. In this tutorial you will write a Python poller that reads holding and input registers, decodes and scales them correctly, and publishes only meaningful changes to an MQTT broker. The result is a small, configurable gateway you can run on any Linux box or industrial PC.
How Modbus TCP Differs from MQTT
Modbus is a request/response protocol. A client (historically "master") asks a server (historically "slave") for a block of registers and gets them back. Nothing is pushed: if you want a value, you poll for it. Modbus has no notion of names, units or data types beyond 16-bit registers and single bits.
MQTT is the opposite: event-driven publish/subscribe through a broker, with topics that carry meaning. The bridge's job is to turn periodic polls into meaningful events. That means three things: knowing the register map, decoding raw registers into engineering values, and deciding when a value is worth publishing.
Modbus Data Model Refresher
| Table | Function code (read) | Size | Access | Typical content |
|---|---|---|---|---|
| Coils | 01 | 1 bit | Read/write | Digital outputs, run commands |
| Discrete inputs | 02 | 1 bit | Read-only | Digital inputs, status bits |
| Holding registers | 03 | 16 bits | Read/write | Setpoints, configuration, often measurements too |
| Input registers | 04 | 16 bits | Read-only | Measurements |
Two details trip up almost every first implementation:
- Addressing offsets. Documentation often lists holding registers as 40001, 40002 and so on. On the wire, 40001 is address 0. Libraries use the zero-based address.
- 32-bit values. Floats and 32-bit integers span two registers, and vendors disagree on word order. Some send the high word first (big-endian, often written ABCD), others the low word first (CDAB). If your temperature reads as 1.2e-38 or 3.5e+12, try swapping words.
Prerequisites
- Python 3.10 or newer.
pymodbus3.8 andpaho-mqtt2.x. Pin pymodbus: its keyword arguments have changed between minor releases.- A Modbus TCP device, or the simulator below.
python -m venv .venv
source .venv/bin/activate
pip install "pymodbus>=3.8,<3.9" "paho-mqtt>=2.0"
Step 1: A Modbus TCP Simulator (Optional)
To develop without hardware, run a small server that exposes a temperature as a 32-bit float in holding registers 0 and 1, a pressure as a scaled integer in input register 0, and a run status in coil 0.
# modbus_sim.py - Modbus TCP server on port 5020 with changing values
import asyncio, random, struct
from pymodbus.datastore import (ModbusSequentialDataBlock, ModbusServerContext,
ModbusSlaveContext)
from pymodbus.server import StartAsyncTcpServer
store = ModbusSlaveContext(
co=ModbusSequentialDataBlock(0, [0] * 10),
di=ModbusSequentialDataBlock(0, [0] * 10),
hr=ModbusSequentialDataBlock(0, [0] * 10),
ir=ModbusSequentialDataBlock(0, [0] * 10),
)
context = ModbusServerContext(slaves=store, single=True)
async def update():
while True:
temp = 60 + random.uniform(-0.3, 0.3)
hi, lo = struct.unpack(">HH", struct.pack(">f", temp))
store.setValues(3, 0, [hi, lo]) # holding registers
store.setValues(4, 0, [int(random.uniform(4.9, 5.1) * 100)]) # input register, bar x100
store.setValues(1, 0, [random.random() > 0.1]) # coil
await asyncio.sleep(1)
async def main():
asyncio.create_task(update())
await StartAsyncTcpServer(context=context, address=("0.0.0.0", 5020))
asyncio.run(main())
Port 5020 avoids needing root privileges for port 502. On a real device, use port 502 and the unit ID from its manual.
Step 2: Describe the Register Map
Keep the map in configuration so adding a tag never requires code changes. Each tag has a table, address, data type, scale, unit, deadband and topic path.
{
"modbus": { "host": "127.0.0.1", "port": 5020, "unit_id": 1, "poll_seconds": 1.0 },
"topic_root": "connectmq/demo/b72f/plant1/utilities/boiler1",
"heartbeat_seconds": 60,
"tags": [
{ "name": "temperature", "table": "holding", "address": 0, "type": "float32", "word_order": "big",
"scale": 1.0, "unit": "degC", "deadband": 0.5 },
{ "name": "pressure", "table": "input", "address": 0, "type": "uint16",
"scale": 0.01, "unit": "bar", "deadband": 0.05 },
{ "name": "running", "table": "coil", "address": 0, "type": "bool", "unit": "" }
]
}
Save it as modbus_map.json and replace b72f with your own random string.
Step 3: The Modbus TCP to MQTT Bridge
The poller below implements report by exception: an analog value is published when it moves by more than its deadband, a boolean when it changes, and every value is republished at a heartbeat interval so consumers know the bridge is alive. Read more about the pattern in our article on report by exception.
# modbus_mqtt_bridge.py - poll Modbus TCP, publish changes to MQTT
import json, logging, struct, time, uuid
import paho.mqtt.client as mqtt
from pymodbus.client import ModbusTcpClient
from pymodbus.exceptions import ModbusException
logging.basicConfig(level=logging.INFO)
log = logging.getLogger("bridge")
CFG = json.load(open("modbus_map.json"))
MQTT_HOST, MQTT_PORT = "broker.connectmq.com", 1883 # production: 8883 + TLS
MQTT_USER, MQTT_PASS = None, None # production: "YOUR_USERNAME", "YOUR_PASSWORD"
CLIENT_ID = f"modbus-bridge-{uuid.uuid4().hex[:8]}" # production: "YOUR_CLIENT_ID"
STATUS = f"{CFG['topic_root']}/bridge_status"
def mqtt_connect():
c = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=CLIENT_ID)
if MQTT_USER:
c.username_pw_set(MQTT_USER, MQTT_PASS)
c.tls_set()
c.will_set(STATUS, "offline", qos=1, retain=True)
c.on_connect = lambda cl, ud, fl, rc, pr: cl.publish(STATUS, "online", qos=1, retain=True)
c.connect(MQTT_HOST, MQTT_PORT, keepalive=30)
c.loop_start()
return c
def decode(tag, regs):
t = tag["type"]
if t == "uint16":
raw = regs[0]
elif t == "int16":
raw = struct.unpack(">h", struct.pack(">H", regs[0]))[0]
elif t in ("float32", "int32", "uint32"):
words = regs[:2] if tag.get("word_order", "big") == "big" else regs[1::-1]
packed = struct.pack(">HH", *words)
raw = struct.unpack({"float32": ">f", "int32": ">i", "uint32": ">I"}[t], packed)[0]
else:
raise ValueError(f"unsupported type {t}")
return round(raw * tag.get("scale", 1.0), 4)
def read_tag(client, tag, unit):
count = 2 if tag["type"] in ("float32", "int32", "uint32") else 1
table = tag["table"]
if table == "coil":
rr = client.read_coils(tag["address"], count=1, slave=unit)
elif table == "discrete":
rr = client.read_discrete_inputs(tag["address"], count=1, slave=unit)
elif table == "holding":
rr = client.read_holding_registers(tag["address"], count=count, slave=unit)
else:
rr = client.read_input_registers(tag["address"], count=count, slave=unit)
if rr.isError():
raise ModbusException(f"{tag['name']}: {rr}")
return bool(rr.bits[0]) if table in ("coil", "discrete") else decode(tag, rr.registers)
def should_publish(tag, value, last):
if last is None:
return True
if isinstance(value, bool):
if value != last["value"]:
return True
elif abs(value - last["value"]) >= tag.get("deadband", 0):
return True
return time.time() - last["sent"] >= CFG["heartbeat_seconds"]
def main():
m = CFG["modbus"]
mq = mqtt_connect()
mb = ModbusTcpClient(m["host"], port=m["port"], timeout=2)
last = {}
while True:
if not mb.connected and not mb.connect():
log.warning("Modbus device unreachable, retrying")
time.sleep(5)
continue
for tag in CFG["tags"]:
topic = f"{CFG['topic_root']}/{tag['name']}"
try:
value = read_tag(mb, tag, m["unit_id"])
quality = "good"
except (ModbusException, ConnectionError, OSError) as exc:
log.error("read failed: %s", exc)
mb.close()
value, quality = None, "bad"
prev = last.get(tag["name"])
if quality == "bad":
if prev is None or prev.get("quality") != "bad":
mq.publish(topic, json.dumps({"value": None, "quality": "bad"}), qos=1, retain=True)
last[tag["name"]] = {"value": None, "quality": "bad", "sent": time.time()}
continue
if prev is None or prev.get("quality") == "bad" or should_publish(tag, value, prev):
payload = {"value": value, "unit": tag.get("unit", ""), "quality": quality,
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}
mq.publish(topic, json.dumps(payload), qos=1, retain=True)
last[tag["name"]] = {"value": value, "quality": quality, "sent": time.time()}
log.info("%s = %s", topic, value)
time.sleep(m["poll_seconds"])
if __name__ == "__main__":
main()
Step 4: Run and Verify
python modbus_sim.py # terminal 1, skip if you have a real device
python modbus_mqtt_bridge.py # terminal 2
mosquitto_sub -h broker.connectmq.com -p 1883 -i modbus-viewer-b72f \
-t "connectmq/demo/b72f/#" -v # terminal 3
You should see the temperature only when it drifts by half a degree, pressure only when it moves by 0.05 bar, and the running flag only when it flips. Each value also reappears once a minute as a heartbeat. That is a fraction of the messages a naive "publish every poll" loop would send, which matters on the public MQTT test broker, where each client may publish at most ten messages per second, and on any metered connection.
Polling Design Tips
- Read contiguous blocks. One request for registers 0 to 19 is far cheaper than twenty requests for one register. Once your map grows, group tags by table and address range and decode from the block.
- Respect the device. Many small PLCs and meters handle only a few requests per second. Start with a one-second poll and measure.
- Use the right unit ID. Modbus TCP devices behind a gateway use the unit ID to address serial devices; standalone devices often ignore it or expect 1 or 255.
- Separate fast and slow tags. Poll alarms and states quickly, energy counters slowly.
- Publish quality explicitly. A failed read should publish
quality: bad, not silently keep the last value. - Never write from the poller by accident. Keep write support out of the bridge, or put it behind a separate, authenticated command topic.
Designing Topics and Payloads for Modbus Data
Raw Modbus gives you addresses, not meaning, so the topic tree is where the bridge adds context. Name topics after what the value is and where it lives, never after the register number: plant1/utilities/boiler1/temperature is useful to anyone, while device3/hr/0 is useful only to whoever wrote the register map. An ISA-95 style hierarchy of site, area, line and equipment keeps hundreds of Modbus devices navigable, and lets consumers subscribe to a whole area with one wildcard.
Keep one value per topic for process data that consumers pick individually, and include unit, quality and timestamp in each payload so a message is self-explanatory when it shows up in a log months later. If the device has many registers that are always used together, such as all the readings of one meter, a single JSON object per device per cycle is a reasonable alternative and lowers the message count. Whichever you choose, apply it consistently across all bridges on the site.
Going to Production
For real plant data, switch to a private broker: set MQTT_PORT to 8883 and fill in the credentials from your managed MQTT broker. With ConnectMQ, every broker comes with a generated username, password and client ID; the client ID is bound to those credentials, so use it rather than a random one. Run the bridge as a systemd service with Restart=always, and consider store and forward buffering if the site's internet link is unreliable.
If your devices speak Modbus RTU over RS-485 rather than Ethernet, the same bridge works with a serial client; our Modbus RTU to MQTT tutorial covers wiring and serial settings. For the overall architecture of gateways, brokers and consumers, see the industrial IoT MQTT guide.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Connection refused |
Wrong IP or port, or device allows one client only | Check port 502, close other masters such as an HMI test tool |
Illegal data address (exception 2) |
Register not mapped, or off-by-one from 4xxxx notation | Subtract 40001 from documented address |
Illegal function (exception 1) |
Wrong table, such as reading input registers as holding | Check the manual's function codes |
| Absurd float values | Word order swapped | Set word_order to little |
| Values correct but 10x or 100x off | Scaled integer register | Set scale from the manual |
| Timeouts under load | Polling too fast or too many single-register reads | Batch reads, slow down the poll |
Get Started with ConnectMQ
Move your Modbus data off the plant network without running a server. Sign up at app.connectmq.com, choose the free Serverless plan (no credit card), create a broker, and paste the generated credentials into the bridge. Your register values appear in the ConnectMQ live topic explorer immediately, and threshold alert rules can warn you when a temperature or pressure leaves its range.
Frequently Asked Questions
How often should I poll Modbus TCP registers?
Start at one second for process values and slower for counters, then adjust to what the device tolerates and what consumers need. Publish on change rather than every poll.
Why do my Modbus float values look wrong?
Most often the two 16-bit words are in the opposite order from what your code assumes. Swap the word order, and check whether the register is actually a scaled integer instead of a float.
Can one bridge poll several Modbus TCP devices?
Yes. Run one client per device, or one client per gateway with different unit IDs, and give each device its own branch in the topic tree.
Should the bridge use QoS 0 or QoS 1?
QoS 1 with retained messages is a good default for process values; it guarantees delivery and lets new subscribers see the current state immediately.



