Tutoriales

Pasarela Modbus a MQTT para PLC: un poller Modbus TCP en Python

Crea una pasarela Modbus a MQTT en Python: lee registros del PLC por Modbus TCP con pymodbus, decodifícalos y publica JSON limpio en topics MQTT testeables.

Actualizado · 9 min de lectura

Pasarela Modbus a MQTT para PLC: un poller Modbus TCP en Python

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:

LadoProtocoloPapel de la pasarela
Red del PLCModbus TCP, puerto 502Cliente Modbus que lee holding/input registers y coils
Red de planta / ITMQTT sobre TCP o TLSCliente 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:

tags.py
# 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:

modbus_read.py
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.

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            # 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 offline en 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:

  1. Abre el cliente MQTT online, conéctate a broker.connectmq.com y suscríbete a testmqtt/acme/plant1/#. Deberías ver al momento el status = online retenido y un mensaje por tag.
  2. Cambia un registro en el simulador. Solo ese tag debería publicar un mensaje nuevo; los que no cambian se quedan en silencio.
  3. Detén la pasarela con kill -9 (sin desconexión limpia). Tras aproximadamente vez y media el keep-alive, status debería pasar a offline.
  4. Detén el simulador. status debería indicar plc-unreachable mientras MQTT sigue conectado.

Observa los topics de la pasarela en tu navegador

Desde un terminal, la misma comprobación es un solo comando:

Ver todos los tags de la línea 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

Endurecimiento para producción

AspectoQué hacer
Reinicios de la pasarelaEjecútala con systemd o Docker con restart: always; el estado y los valores retenidos se recuperan solos
Carga del PLCAgrupa registros contiguos, no leas más rápido de lo necesario, usa una conexión por PLC
Muchos PLCUn hilo o tarea asyncio por PLC, cada uno con su propio topic de estado
Caída del brokerpaho se reconecta automáticamente tras loop_start(); guarda lecturas en local si no puedes permitirte huecos
Escrituras al PLCDesactivadas por defecto; si las necesitas, suscríbete a un topic .../cmd dedicado, usa una lista de registros permitidos y valida rangos
SeguridadSepara 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.