Una pasarela Modbus a MQTT lee registros de un PLC por Modbus TCP y los publica como mensajes MQTT, de modo que dashboards, historiadores y servicios cloud pueden suscribirse a los datos de planta sin tocar nunca el PLC. Puedes construir una fiable en unas 150 líneas de Python con pymodbus y paho-mqtt.
Este tutorial construye esa pasarela paso a paso: un mapa de tags, un bucle de lectura, decodificación de valores de 32 bits, publicación por excepción, estado online/offline con Last Will y una forma de probarlo todo antes de acercarse a una línea de producción.
Cómo encaja la pasarela
Modbus es un protocolo de petición/respuesta: un maestro (cliente) pide registros a un esclavo (servidor), una petición cada vez. MQTT es publicación/suscripción: los productores publican una vez y cualquier número de consumidores se suscribe. La pasarela traduce entre ambos mundos:
| Lado | Protocolo | Papel de la pasarela |
|---|---|---|
| Red del PLC | Modbus TCP, puerto 502 | Cliente Modbus que lee holding/input registers y coils |
| Red de planta / IT | MQTT sobre TCP o TLS | Cliente MQTT que publica un topic por tag |
Ejecuta la pasarela en un equipo edge (PC industrial, Raspberry Pi, un contenedor en el servidor de línea) que alcance ambas redes. El PLC nunca ve tráfico MQTT y los consumidores MQTT nunca abren un socket Modbus: justo la separación que quieren los equipos de OT. Para el panorama general, consulta MQTT para IIoT e Industria 4.0.
1. Describe tus tags
Modbus no tiene nombres, solo direcciones. La primera tarea es un mapa de tags que diga qué significa cada registro. Guárdalo en un archivo junto al código para revisarlo como cualquier configuración:
# nombre, función, dirección, tipo de dato, escala, unidad
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, ""),
]Aquí las direcciones son direcciones de protocolo empezando en cero. La documentación de los fabricantes suele usar la numeración "40001", empezando en uno: el holding register 40001 es la dirección 0 y el 40003 es la dirección 2. El error de uno es el fallo Modbus más común, así que comprueba un valor conocido antes de fiarte del resto.
2. Lee y decodifica registros
Un registro Modbus tiene 16 bits. Los floats y los contadores de 32 bits ocupan dos registros, y los fabricantes no se ponen de acuerdo en qué palabra va primero. Decodificar con struct deja explícito el orden de palabras:
import struct
from pymodbus.client import ModbusTcpClient
WORD_ORDER_BIG = True # pon False en dispositivos con "word swap" little-endian
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)En producción conviene leer bloques contiguos (por ejemplo los registros 0–5 en una sola petición) en lugar de una petición por tag: un PLC responde antes a una petición grande que a seis pequeñas. La versión por tag de arriba es más fácil de leer y sirve para unas pocas decenas de tags.
3. Publica en MQTT por excepción
Ahora combina el poller con un cliente MQTT. La pasarela publica un topic por tag, envía un valor solo cuando cambia más allá de una banda muerta, marca los valores como retained para que los nuevos suscriptores reciban el estado actual al instante y usa un Last Will para anunciar cuando se desconecta.
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 # segundos, cambio relativo (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 conectado:", 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)Algunas decisiones que conviene explicar:
- Un topic por tag (
.../plc1/temperature) permite a los consumidores suscribirse exactamente a lo que necesitan, y los wildcards como.../plc1/+siguen dándoles todo. Prueba los patrones con el topic matcher. - Retained + QoS 1 significa que un dashboard abierto a las 3 de la mañana muestra al instante el último valor conocido, y que los mensajes sobreviven a cortes breves de red. Consulta mensajes retenidos.
- Topic de estado con Last Will: distingue "la temperatura está estable" de "la pasarela ha muerto". El broker publica
offlineen tu nombre si la conexión de la pasarela cae sin una desconexión limpia. - Timestamps en el payload: registran cuándo se leyó el valor, no cuándo llegó; importante para los historiadores.
La estructura de topics sigue una jerarquía de estilo ISA-95 (empresa / planta / línea / dispositivo), que es el punto de partida de un Unified Namespace.
4. Pruébalo sin un PLC de producción
Nunca desarrolles contra una línea en marcha. Empieza con un simulador Modbus: pymodbus incluye uno, y herramientas como diagslave o ModbusPal sirven registros configurables en el puerto 502 (o 5020 si no eres root). Apunta PLC_HOST a él y ejecuta la pasarela.
Después comprueba el lado MQTT:
- Abre el cliente MQTT online, conéctate a
broker.connectmq.comy suscríbete atestmqtt/acme/plant1/#. Deberías ver al momento elstatus=onlineretenido y un mensaje por tag. - Cambia un registro en el simulador. Solo ese tag debería publicar un mensaje nuevo; los que no cambian se quedan en silencio.
- Detén la pasarela con
kill -9(sin desconexión limpia). Tras aproximadamente vez y media el keep-alive,statusdebería pasar aoffline. - Detén el simulador.
statusdebería indicarplc-unreachablemientras MQTT sigue conectado.
Observa los topics de la pasarela en tu navegador
Desde un terminal, la misma comprobación es un solo comando:
mosquitto_sub -h broker.connectmq.com -p 8883 --capath /etc/ssl/certs \
-u "$MQTT_USER" -P "$MQTT_PASS" \
-t 'testmqtt/acme/plant1/line2/#' -vEndurecimiento para producción
| Aspecto | Qué hacer |
|---|---|
| Reinicios de la pasarela | Ejecútala con systemd o Docker con restart: always; el estado y los valores retenidos se recuperan solos |
| Carga del PLC | Agrupa registros contiguos, no leas más rápido de lo necesario, usa una conexión por PLC |
| Muchos PLC | Un hilo o tarea asyncio por PLC, cada uno con su propio topic de estado |
| Caída del broker | paho se reconecta automáticamente tras loop_start(); guarda lecturas en local si no puedes permitirte huecos |
| Escrituras al PLC | Desactivadas por defecto; si las necesitas, suscríbete a un topic .../cmd dedicado, usa una lista de registros permitidos y valida rangos |
| Seguridad | Separa las redes OT e IT, nunca expongas el puerto 502 a internet, usa TLS y credenciales por pasarela para MQTT |
Modbus no tiene ninguna autenticación, así que la pasarela suele ser la única frontera controlada entre el PLC y todo lo demás. Mantén sus permisos MQTT acotados y su camino de escritura cerrado salvo que de verdad lo necesites.
Si tus plantas pasan más adelante a Sparkplug B o necesitan una gestión de estado estándar para muchos dispositivos, el mismo núcleo de lectura puede alimentar un edge node Sparkplug B en lugar de topics JSON simples.
Una pasarela Modbus a MQTT es poco código con mucho efecto: convierte registros que solo entiende el programador del PLC en datos con nombre, con timestamp y a los que cualquiera puede suscribirse. Empieza con un mapa de tags claro, publica por cambio con valores retenidos y Last Will, prueba contra un simulador y el cliente online, y solo entonces conéctala a la línea.
Preguntas frecuentes
¿Puede un PLC publicar en MQTT directamente en lugar de usar una pasarela Modbus?
Muchos PLC y controladores edge recientes incluyen un cliente MQTT, y en ese caso deberías usarlo. Una pasarela Modbus a MQTT es la herramienta adecuada para la enorme base instalada de PLC, medidores y variadores que solo hablan Modbus TCP o RTU.
¿Con qué frecuencia debo leer los registros Modbus?
Tan despacio como permita el proceso. De uno a cinco segundos es habitual para temperaturas y contadores; las señales rápidas pueden necesitar 100–250 ms. Lee rápido si hace falta, pero publica solo cuando cambie el valor (con banda muerta) para que el tráfico MQTT sea pequeño.
¿Debe la pasarela escribir valores en el PLC desde MQTT?
Solo de forma deliberada. Limita las escrituras a una lista explícita de registros, valida los rangos, protege el topic de comandos con ACL por topic y registra cada escritura. Muchas plantas mantienen la pasarela en solo lectura.


