MQTT 5 añadió funciones que cambian el comportamiento de las aplicaciones: metadatos en user properties, mensajes que expiran, paquetes más cortos gracias a los topic aliases, sesiones con tiempo de vida. Cada una depende de que tanto la librería cliente como el broker la implementen bien, así que prueba las funciones de MQTT 5 de extremo a extremo antes de depender de ellas.
Este artículo te da tests pequeños y ejecutables con MQTT.js 5 (Node.js) y paho-mqtt 2.x (Python). Cada test publica, observa lo que llega y hace una aserción, de modo que puede ejecutarse en CI igual que en tu portátil.
Qué probar y qué significa «pasa»
| Función | Test | Condición de éxito |
|---|---|---|
| User properties | Publicar con pares clave/valor | El suscriptor recibe los mismos pares |
| Content type, correlation data | Publicar con ambos definidos | El suscriptor recibe ambos sin cambios |
| Message expiry (retenido) | Mensaje retenido con expiry de 3 s, suscribirse a los 5 s | No se entrega nada |
| Message expiry (en cola) | Encolar mensajes para una sesión offline | El mensaje no expirado llega con un intervalo reducido; el expirado se descarta |
| Session expiry | Reconectar con clean_start=False | session present es true y llegan los mensajes en cola |
| Topic aliases | Registrar un alias y publicar con topic vacío | El suscriptor ve el nombre completo del topic |
| No local | Suscribirse y publicar en la misma conexión con nl | El cliente no recibe su propio mensaje |
Primero, confirma que el broker acepta conexiones MQTT 5 y revisa sus límites en el CONNACK (topic alias maximum, maximum QoS) con la sonda de funciones del broker. Un test que falla porque el broker anuncia TopicAliasMaximum 0 es un hallazgo de configuración, no un bug.
Preparación
npm install mqtt@5
pip install "paho-mqtt>=2.0"Todos los ejemplos usan broker.connectmq.com:1883 y un prefijo de topic aleatorio bajo testmqtt/mqtt5/ para que ejecuciones paralelas no vean los mensajes de las demás. Añade username y password ($MQTT_USER / $MQTT_PASS) si tu broker los exige.
MQTT.js: propiedades, expiry, alias y no-local
El script siguiente ejecuta cuatro tests en secuencia y termina con código distinto de cero al primer fallo. nextMessage espera un mensaje o agota el tiempo, lo que hace que «no debe llegar nada» sea tan fácil de comprobar como «esto debe llegar».
import mqtt from 'mqtt';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
const URL = 'mqtt://broker.connectmq.com:1883';
const opts = { protocolVersion: 5, username: process.env.MQTT_USER, password: process.env.MQTT_PASS };
const base = `testmqtt/mqtt5/${randomUUID().slice(0, 8)}`;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const nextMessage = (client, timeoutMs = 5000) =>
new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('timeout')), timeoutMs);
client.once('message', (topic, payload, packet) => {
clearTimeout(timer);
resolve({ topic, payload, packet });
});
});
const sub = await mqtt.connectAsync(URL, opts);
const pub = await mqtt.connectAsync(URL, opts);
// 1. User properties, content type, correlation data
await sub.subscribeAsync(`${base}/props`, { qos: 1 });
let incoming = nextMessage(sub);
await pub.publishAsync(`${base}/props`, '{"temp":21.5}', {
qos: 1,
properties: {
userProperties: { source: 'ci', 'trace-id': 'abc123' },
contentType: 'application/json',
responseTopic: `${base}/reply`,
correlationData: Buffer.from('req-42'),
},
});
const { packet } = await incoming;
assert.equal(packet.properties.userProperties.source, 'ci');
assert.equal(packet.properties.userProperties['trace-id'], 'abc123');
assert.equal(packet.properties.contentType, 'application/json');
assert.equal(packet.properties.correlationData.toString(), 'req-42');
console.log('PASS user properties / content type / correlation data');
// 2. Retained message expiry
await pub.publishAsync(`${base}/expiry`, 'short-lived', {
qos: 1, retain: true, properties: { messageExpiryInterval: 3 },
});
await sleep(5000);
const late = await mqtt.connectAsync(URL, opts);
incoming = nextMessage(late, 3000);
await late.subscribeAsync(`${base}/expiry`, { qos: 1 });
await assert.rejects(incoming, /timeout/, 'expired retained message was delivered');
console.log('PASS retained message expired');
// 3. Topic alias: register alias 1, then publish with an empty topic
const aliasClient = mqtt.connect(URL, opts);
const connack = await new Promise((resolve, reject) => {
aliasClient.once('connect', resolve);
aliasClient.once('error', reject);
});
const aliasMax = connack.properties?.topicAliasMaximum ?? 0;
if (aliasMax === 0) {
console.log('SKIP topic alias: broker advertises TopicAliasMaximum 0');
} else {
const long = `${base}/alias/site-01/building-a/floor-3/sensor-temperature`;
await sub.subscribeAsync(long, { qos: 1 });
incoming = nextMessage(sub);
await aliasClient.publishAsync(long, 'first', { qos: 1, properties: { topicAlias: 1 } });
assert.equal((await incoming).topic, long);
incoming = nextMessage(sub);
await aliasClient.publishAsync('', 'second', { qos: 1, properties: { topicAlias: 1 } });
assert.equal((await incoming).topic, long, 'broker did not resolve the alias');
console.log(`PASS topic alias (broker allows ${aliasMax})`);
}
// 4. No local: a client must not receive its own publish
await pub.subscribeAsync(`${base}/nolocal`, { qos: 1, nl: true });
incoming = nextMessage(pub, 2000);
await pub.publishAsync(`${base}/nolocal`, 'echo?', { qos: 1 });
await assert.rejects(incoming, /timeout/, 'received own message despite no-local');
console.log('PASS no local');
await Promise.all([sub, pub, late, aliasClient].map((c) => c.endAsync()));Ejecútalo con node mqtt5.test.mjs. Conviene conocer algunos detalles:
- Las user properties en MQTT.js son un objeto plano. Si una clave aparece varias veces en el cable, su valor se convierte en un array: MQTT 5 permite claves duplicadas y su orden debe conservarse.
- El orden importa en el test de expiry. Registra el listener de mensajes antes de suscribirte, porque un mensaje retenido puede llegar antes de que
subscribeAsyncse resuelva. - Topic alias fuera de rango. MQTT.js se niega a enviar un alias mayor que el
TopicAliasMaximumdel broker y emite un error. Un broker que lo recibe igualmente desconecta con0x94 Topic Alias invalid. Más en el test de topic alias. - No local es una opción de suscripción, definida por suscripción. Las demás opciones (retain as published, retain handling) se explican en opciones de suscripción MQTT.
paho-mqtt: session expiry y expiración de mensajes en cola
El test de MQTT 5 más valioso es también el que más se omite: qué pasa con los mensajes mientras un dispositivo está offline. El script crea una sesión persistente, se desconecta, publica dos mensajes (uno con expiry de 60 s y otro de 2 s), espera y se reconecta.
import time, uuid
import paho.mqtt.client as mqtt
from paho.mqtt.packettypes import PacketTypes
from paho.mqtt.properties import Properties
HOST, PORT = "broker.connectmq.com", 1883
TOPIC = f"testmqtt/mqtt5/{uuid.uuid4().hex[:8]}/queued"
CLIENT_ID = f"mqtt5-session-{uuid.uuid4().hex[:8]}"
received = []
def on_connect(client, userdata, flags, reason_code, properties):
print("connected:", reason_code, "| session present:", flags.session_present)
def on_message(client, userdata, msg):
expiry = getattr(msg.properties, "MessageExpiryInterval", None)
user = getattr(msg.properties, "UserProperty", None)
print(f"got {msg.payload.decode()!r} expiry left: {expiry} user properties: {user}")
received.append(msg.payload.decode())
def session_client():
c = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=CLIENT_ID,
protocol=mqtt.MQTTv5)
c.on_connect, c.on_message = on_connect, on_message
props = Properties(PacketTypes.CONNECT)
props.SessionExpiryInterval = 120 # keep the session 2 minutes after disconnect
c.connect(HOST, PORT, clean_start=False, properties=props)
c.loop_start()
time.sleep(1)
return c
# 1. Create the session and subscription, then go offline
s = session_client()
s.subscribe(TOPIC, qos=1)
time.sleep(1)
s.disconnect(); s.loop_stop()
# 2. Publish while the subscriber is offline
pub = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, protocol=mqtt.MQTTv5)
pub.connect(HOST, PORT); pub.loop_start()
for i, expiry in enumerate((60, 2)):
props = Properties(PacketTypes.PUBLISH)
props.MessageExpiryInterval = expiry
props.UserProperty = ("test", f"msg-{i}")
pub.publish(TOPIC, f"msg-{i}", qos=1, properties=props).wait_for_publish()
pub.disconnect(); pub.loop_stop()
time.sleep(5) # longer than the 2 s expiry
# 3. Reconnect: msg-0 arrives with less than 60 s left, msg-1 has expired
s = session_client()
time.sleep(3)
s.disconnect(); s.loop_stop()
assert received == ["msg-0"], received
print("PASS session kept, unexpired message delivered, expired message dropped")Salida esperada: la segunda conexión imprime session present: True, y msg-0 llega con un expiry de unos 54–55 segundos. El broker debe reenviar el tiempo de vida restante, no el valor original: si ves 60, el broker no lo está decrementando y los consumidores no pueden saber cuán antiguo es el mensaje. Si session present es False, el broker no conservó la sesión: revisa su session expiry máximo y si el client ID cambió. El test de session expiry profundiza en casos límite como el expiry al desconectar.
Request/response con response topic y correlation data
MQTT 5 estandariza request/response: quien pide define un response topic y correlation data; quien responde publica la respuesta en ese topic y copia la correlation data. El test 1 de arriba ya comprueba que el broker reenvía ambas propiedades. Para probar un responder real, suscríbete al response topic, envía una petición y comprueba que la respuesta trae la misma correlation data dentro de tu timeout. El patrón y un ejemplo completo están en el test de request/response MQTT.
Probar flotas mixtas MQTT 3.1.1 y MQTT 5
La mayoría de flotas son mixtas durante años. Añade una ejecución más del test de propiedades con el suscriptor conectado como 3.1.1 (protocolVersion: 4 en MQTT.js, protocol=mqtt.MQTTv311 en paho):
- El mensaje debe seguir llegando.
packet.propertiesno existe: los clientes 3.1.1 nunca ven user properties, content type ni correlation data.- El message expiry sigue funcionando, porque lo aplica el broker.
Si tu backend depende de user properties para enrutar o trazar, esta ejecución muestra qué dispositivos tendrán que recurrir al payload. Consulta MQTT 5 vs 3.1.1 para ver la lista completa de diferencias.
Mira los tests de MQTT 5 en el navegador
Las funciones de MQTT 5 son tan fiables como la implementación más débil entre tu publisher, el broker y tu suscriptor. Unos cientos de líneas de tests (ida y vuelta de propiedades, expiry, alias, sesiones) detectan las diferencias pronto. Mantenlos en CI y vuelve a ejecutarlos tras cada actualización del broker o de la librería.
Preguntas frecuentes
¿Funcionan las propiedades de MQTT 5 si el suscriptor usa MQTT 3.1.1?
El mensaje se entrega, pero un suscriptor 3.1.1 no puede recibir propiedades: user properties, content type, response topic y correlation data se descartan para esa conexión. El message expiry sigue aplicándose en el broker.
¿Por qué no expira mi mensaje retenido?
Comprueba que publicaste con MQTT 5 (en conexiones 3.1.1 las propiedades se ignoran), que el expiry interval está en el PUBLISH y no en el CONNECT, y que tu broker implementa la expiración de mensajes retenidos. Prueba con un intervalo corto y un suscriptor nuevo.
¿Tengo que gestionar los topic aliases yo mismo?
No necesariamente. MQTT.js puede asignar y reutilizar alias automáticamente (autoAssignTopicAlias, autoUseTopicAlias) y muchas librerías resuelven los alias entrantes por ti. Gestiónalos a mano solo cuando necesites un control exacto del ancho de banda.


