OPC UA PubSub sobre MQTT combina el modelo de información de OPC UA con la distribución basada en broker de MQTT, de modo que un dispositivo OPC UA puede publicar los datos una sola vez y cualquier número de suscriptores puede recibirlos a través de un broker estándar. En este artículo verás cómo estructura PubSub la Parte 14 de la especificación OPC UA, la diferencia entre las codificaciones JSON y UADP, cómo se organizan los topics y los metadatos, y cómo fluyen estos mensajes a través de un broker MQTT corriente. Además, publicarás y decodificarás tú mismo un NetworkMessage JSON.
Por qué OPC UA añadió PubSub
El OPC UA clásico es cliente/servidor: un cliente abre una sesión, crea suscripciones y recibe notificaciones a través de esa sesión. Es potente, pero escala mal cuando muchos consumidores quieren los mismos datos y exige que los clientes alcancen directamente a los servidores, algo incómodo a través de firewalls y hacia el cloud. Nuestra comparativa OPC UA vs MQTT trata esa tensión en detalle.
La Parte 14 de la especificación, introducida con OPC UA 1.04 y ampliada en la 1.05, añade un modelo de publicación/suscripción. Los publicadores envían mensajes sin saber quién los recibe; los suscriptores filtran lo que necesitan. La Parte 14 define dos familias de transporte:
- Sin broker, como UDP multicast o Ethernet (incluido TSN), para comunicación determinista y de baja latencia entre controladores de la misma red.
- Basada en broker, con MQTT o AMQP, para distribuir datos a muchos consumidores, entre redes y hacia el cloud.
Este artículo trata de la segunda familia, con MQTT como transporte.
Los componentes de PubSub
La Parte 14 introduce un vocabulario que conviene conocer antes de mirar los mensajes.
| Concepto | Qué es |
|---|---|
| PublishedDataSet | Una colección con nombre de variables o eventos a publicar, como todos los valores del husillo de una CNC |
| DataSetWriter | Convierte un PublishedDataSet en DataSetMessages, con un DataSetWriterId numérico |
| WriterGroup | Agrupa DataSetWriters que comparten ajustes como el intervalo de publicación y la codificación; produce NetworkMessages |
| PublisherId | Identifica la aplicación publicadora, como número o como cadena |
| NetworkMessage | La unidad que viaja por el transporte: una cabecera más uno o varios DataSetMessages |
| DataSetMessage | Los valores de un dataset en un momento dado, ya sea un key frame completo o un delta frame |
| DataSetMetaData | Describe los campos de un dataset: nombres, tipos de datos, versiones |
| DataSetReader | En el lado del suscriptor, filtra y decodifica los mensajes de un dataset |
La idea importante es que el payload no es un JSON arbitrario inventado por cada fabricante. Es una estructura de mensaje definida, con números de secuencia, versiones de metadatos y una forma de describir los campos, para que un suscriptor genérico pueda interpretar los datos de cualquier publicador conforme.
Codificación JSON frente a UADP
La Parte 14 define dos codificaciones de mensaje, y ambas pueden transportarse sobre MQTT.
| Aspecto | Codificación JSON | Codificación UADP (binaria) |
|---|---|---|
| Formato | JSON legible por humanos | Binario compacto, tipos binarios de OPC UA |
| Tamaño | Mayor | Mucho menor |
| Legibilidad | Legible en cualquier cliente MQTT o log | Necesita un decodificador OPC UA PubSub |
| Consumidores típicos | Servicios cloud, herramientas de IT, dashboards, scripts | Stacks OPC UA, controladores, enlaces con ancho de banda limitado |
| Nombres de campo | Pueden incluirse en el mensaje | Normalmente requieren metadatos para interpretarse |
| Ideal para | Integración e interoperabilidad con IT | Eficiencia y determinismo |
Para la integración cloud, JSON es la opción habitual porque cualquier lenguaje puede parsearlo y puedes inspeccionar los mensajes directamente en un explorador de topics. UADP tiene sentido cuando importa el ancho de banda o cuando ambos extremos son stacks OPC UA.
Anatomía de un NetworkMessage JSON
Un mensaje de datos codificado en JSON tiene este aspecto (conjunto de campos simplificado para facilitar la lectura):
{
"MessageId": "8f1c2e7a-0b44-4c55-9a1e-3f0d9d2b6c11",
"MessageType": "ua-data",
"PublisherId": "cnc-cell-07",
"Messages": [
{
"DataSetWriterId": 101,
"SequenceNumber": 4521,
"MetaDataVersion": { "MajorVersion": 1, "MinorVersion": 3 },
"Timestamp": "2026-10-16T09:30:12.512Z",
"MessageType": "ua-keyframe",
"Payload": {
"SpindleTemp": 47.3,
"SpindleSpeed": 12000,
"PartCount": 1843
}
}
]
}
Campos clave que debes entender:
- MessageType en el nivel superior distingue los datos (
ua-data) de los metadatos (ua-metadata) y, en versiones más recientes, de los mensajes de estado y otros mensajes de descubrimiento. - PublisherId y DataSetWriterId juntos identifican exactamente de qué dataset se trata.
- SequenceNumber permite a los suscriptores detectar huecos y duplicados.
- MetaDataVersion indica al suscriptor qué versión de la descripción de campos se aplica. Si cambia, el suscriptor debe obtener los nuevos metadatos antes de fiarse de las posiciones o los tipos de los campos.
- Key frames frente a delta frames: un key frame lleva todos los campos; un delta frame lleva solo los campos que han cambiado, lo que reduce el tráfico para datos que cambian lentamente.
Los valores del payload pueden codificarse en formas más compactas o más detalladas (la especificación distingue codificaciones reversibles y no reversibles, y la 1.05 lo refina). Una forma compacta da valores simples como en el ejemplo; una forma detallada envuelve cada valor con su tipo, estado y marcas de tiempo para que pueda decodificarse de vuelta a un Variant OPC UA exacto.
Topics y metadatos en el broker
En OPC UA 1.04, el topic MQTT (llamado queue name) simplemente se configura en cada WriterGroup o DataSetWriter. OPC UA 1.05 añade un árbol de topics por defecto recomendado para que los suscriptores puedan descubrir a los publicadores de forma predecible. Tiene esta forma:
<prefix>/<encoding>/<message type>/<PublisherId>/<WriterGroup>/<DataSetWriter>
opcua/json/data/cnc-cell-07/fast/spindle
opcua/json/metadata/cnc-cell-07/fast/spindle
opcua/json/status/cnc-cell-07
El prefijo por defecto es opcua, pero es configurable, algo importante cuando compartes un broker con otros sistemas. Merece la pena seguir tres convenciones:
- Los mensajes de metadatos se publican como retenidos, para que un suscriptor que se conecte más tarde reciba la descripción de los campos al instante.
- Los mensajes de estado son retenidos y van emparejados con un Last Will, para que los suscriptores sepan si un publicador está en línea.
- La QoS es configurable por writer group, y se corresponde con QoS 0, 1 o 2 de MQTT (best effort, al menos una vez, exactamente una vez).
Nada de esto necesita soporte especial del broker. Los retained messages, el Last Will, los comodines y la QoS son funciones estándar de MQTT, y precisamente por eso la Parte 14 eligió MQTT como transporte. Para repasar las garantías de entrega, consulta los niveles de QoS de MQTT explicados.
Cómo fluyen los mensajes a través de un broker estándar
Desde el punto de vista del broker, un publicador OPC UA PubSub es simplemente otro cliente MQTT. Se conecta, opcionalmente se autentica, establece un Last Will y publica payloads en topics. El broker no analiza la estructura OPC UA; enruta bytes según los topics y las suscripciones. Eso significa que cualquier broker MQTT conforme con los estándares puede transportar tráfico OPC UA PubSub, incluido un broker MQTT gestionado como ConnectMQ.
Un suscriptor normalmente:
- Se suscribe al topic de metadatos, recibe los metadatos retenidos y construye un mapa de campos.
- Se suscribe al topic de datos o a un comodín como
opcua/json/data/#. - Comprueba
MetaDataVersionfrente a sus metadatos en caché ySequenceNumberpara detectar huecos. - Aplica los delta frames a su último key frame conocido para mantener el estado actual.
Práctica: publicar y decodificar un NetworkMessage JSON
El servidor MQTT público gratuito, pensado solo para pruebas, limita los topics a ocho niveles, así que el ejemplo sustituye el prefijo opcua por un prefijo único propio. Instala primero paho-mqtt 2.x con pip install "paho-mqtt>=2.0".
El publicador envía un mensaje de metadatos retenido y después un key frame cada dos segundos:
# pubsub_publisher.py - emite mensajes JSON al estilo de OPC UA Parte 14 sobre MQTT
import json, random, time, uuid
from datetime import datetime, timezone
import paho.mqtt.client as mqtt
PREFIX = "connectmq/demo/c3d9/json" # sustituye al "opcua/json" por defecto
PUBLISHER_ID = "cnc-cell-07"
DATA_TOPIC = f"{PREFIX}/data/{PUBLISHER_ID}/fast/spindle"
META_TOPIC = f"{PREFIX}/metadata/{PUBLISHER_ID}/fast/spindle"
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=f"uapub-{uuid.uuid4().hex[:8]}")
client.connect("broker.connectmq.com", 1883, keepalive=30)
client.loop_start()
metadata = {
"MessageId": str(uuid.uuid4()), "MessageType": "ua-metadata",
"PublisherId": PUBLISHER_ID, "DataSetWriterId": 101,
"MetaData": {
"Name": "Spindle",
"Fields": [
{"Name": "SpindleTemp", "BuiltInType": 11}, # Double
{"Name": "SpindleSpeed", "BuiltInType": 11},
{"Name": "PartCount", "BuiltInType": 7} # UInt32
],
"ConfigurationVersion": {"MajorVersion": 1, "MinorVersion": 3}
}
}
client.publish(META_TOPIC, json.dumps(metadata), qos=1, retain=True)
seq, parts = 0, 1840
while True:
seq += 1
parts += random.choice([0, 0, 1])
msg = {
"MessageId": str(uuid.uuid4()), "MessageType": "ua-data", "PublisherId": PUBLISHER_ID,
"Messages": [{
"DataSetWriterId": 101, "SequenceNumber": seq,
"MetaDataVersion": {"MajorVersion": 1, "MinorVersion": 3},
"Timestamp": datetime.now(timezone.utc).isoformat(timespec="milliseconds"),
"MessageType": "ua-keyframe",
"Payload": {"SpindleTemp": round(random.uniform(44, 50), 2),
"SpindleSpeed": random.choice([0.0, 8000.0, 12000.0]),
"PartCount": parts}
}]
}
client.publish(DATA_TOPIC, json.dumps(msg), qos=1)
time.sleep(2)
El suscriptor guarda en caché los metadatos, comprueba las versiones y detecta huecos en la secuencia:
# pubsub_subscriber.py - decodifica NetworkMessages JSON de OPC UA desde MQTT
import json, uuid
import paho.mqtt.client as mqtt
PREFIX = "connectmq/demo/c3d9/json"
meta_versions, last_seq = {}, {}
def on_connect(client, userdata, flags, reason_code, properties):
client.subscribe(f"{PREFIX}/metadata/#", qos=1)
client.subscribe(f"{PREFIX}/data/#", qos=1)
def on_message(client, userdata, message):
nm = json.loads(message.payload)
if nm["MessageType"] == "ua-metadata":
key = (nm["PublisherId"], nm["DataSetWriterId"])
meta_versions[key] = nm["MetaData"]["ConfigurationVersion"]
print("metadata", key, [f["Name"] for f in nm["MetaData"]["Fields"]])
return
for dsm in nm.get("Messages", []):
key = (nm["PublisherId"], dsm["DataSetWriterId"])
if meta_versions.get(key) != dsm["MetaDataVersion"]:
print("warning: metadata missing or outdated for", key)
prev = last_seq.get(key)
if prev is not None and dsm["SequenceNumber"] != prev + 1:
print(f"gap detected: {prev} -> {dsm['SequenceNumber']}")
last_seq[key] = dsm["SequenceNumber"]
print(dsm["Timestamp"], dsm["Payload"])
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=f"uasub-{uuid.uuid4().hex[:8]}")
client.on_connect = on_connect
client.on_message = on_message
client.connect("broker.connectmq.com", 1883, keepalive=30)
client.loop_forever()
Ejecuta el publicador y arranca el suscriptor cuando quieras. Como los metadatos están retenidos, el suscriptor imprime primero la lista de campos y después los datos. Sustituye c3d9 por tu propia cadena aleatoria.
Consideraciones prácticas
- Combinarlo con un Unified Namespace. Los topics de OPC UA PubSub se organizan por publicador y writer group, no por jerarquía de planta. Muchos equipos mantienen el árbol PubSub tal cual y ejecutan un pequeño servicio que republica campos seleccionados en un namespace de estilo ISA-95; consulta nuestra guía de topics MQTT ISA-95.
- Tamaño de los mensajes. Los key frames con muchos campos pueden crecer mucho. Comprueba el límite de tamaño de mensaje del broker para tu plan y, para datasets amplios, prefiere delta frames o varios writer groups.
- Seguridad. Sobre MQTT, proteges el transporte con TLS y credenciales del broker. La Parte 14 también define firma y cifrado a nivel de mensaje con un servicio de claves, usados sobre todo con UADP.
- Herramientas. Stacks de código abierto como open62541 incluyen PubSub con soporte MQTT, y varios PLC y gateways comerciales pueden publicar directamente JSON de la Parte 14.
Para el contexto más amplio de dónde encaja esto en una arquitectura de planta, lee nuestra guía de IoT industrial con MQTT.
Empieza con ConnectMQ
Apunta tu publicador OPC UA PubSub a un broker MQTT gestionado que no tengas que operar. Crea uno gratis en app.connectmq.com con el plan Serverless y sin tarjeta de crédito, usa el nombre de usuario, la contraseña y el client ID generados con TLS en el puerto 8883, e inspecciona tus NetworkMessages en el explorador de topics en vivo del portal. ConnectMQ soporta MQTT 3.1.1 y 5.0, retained messages y Last Will, todo aquello en lo que se apoya la Parte 14.
Preguntas frecuentes
¿El broker MQTT necesita entender OPC UA para que PubSub funcione?
No. El broker enruta los mensajes por topic y trata los payloads como bytes. Cualquier broker MQTT conforme con los estándares puede transportar tráfico OPC UA PubSub.
¿Debo usar codificación JSON o UADP para OPC UA PubSub sobre MQTT?
Usa JSON cuando los datos los consuman sistemas de IT o cloud, porque es legible y fácil de parsear. Usa UADP cuando el ancho de banda sea escaso o ambos extremos sean stacks OPC UA.
¿Qué diferencia hay entre un key frame y un delta frame?
Un key frame contiene todos los campos del dataset; un delta frame contiene solo los campos que han cambiado. Los suscriptores aplican los delta frames al último key frame para mantener el estado completo.
¿Por qué deben ser retenidos los mensajes de metadatos?
Para que un suscriptor que se conecte después de que arrancara el publicador reciba igualmente la descripción de los campos al instante, sin esperar a que el publicador la vuelva a enviar.



