Pruebas de brokers

Crea un simulador de dispositivos MQTT en Python

Crea un simulador de dispositivos MQTT con asyncio en Python: 1.000 dispositivos virtuales con client ID únicos, Last Will, intervalos realistas y fallos.

Actualizado · 10 min de lectura

Crea un simulador de dispositivos MQTT en Python

Un simulador de dispositivos MQTT te permite probar un broker, un backend o un dashboard contra una flota que todavía no existe. El truco es que cada dispositivo virtual se comporte como uno real: su propia conexión y un client ID único, un Last Will que lo marca como offline, telemetría a intervalos ligeramente irregulares, comandos a los que responde y algún corte de corriente de vez en cuando.

Esta guía construye ese simulador en unas 120 líneas de Python con asyncio y aiomqtt, lo escala a 1.000 dispositivos y muestra cómo verificar lo que ve el broker.

Qué hace un dispositivo virtual realista

ComportamientoPor qué importa en las pruebas
Conexión propia, client ID únicoEjercita las sesiones por cliente, las ACL y los límites de conexión. Los ID duplicados se expulsan entre sí.
Arranque escalonadoLas flotas reales no se conectan en el mismo milisegundo, salvo cuando vuelve la luz tras un apagón.
Estado online retenido + Last Will offlinePermite que los dashboards muestren presencia y prueba que el broker dispara los wills.
Intervalo de publicación con jitterEvita ráfagas perfectamente sincronizadas que los dispositivos reales nunca producen.
Flota heterogéneaUnos dispositivos informan cada 5 s, otros cada 30 s; los payloads varían.
Topic de comandosPrueba el camino de bajada: backend → broker → dispositivo → acknowledgement.
Fallos por pérdida de alimentaciónEl dispositivo desaparece sin DISCONNECT, luego reinicia y se reconecta con backoff.

Estructura de topics

Cada dispositivo es dueño de un pequeño subárbol. Un prefijo por ejecución mantiene separadas las pruebas que corren en paralelo:

TopicDirecciónRetenidoContenido
testmqtt/sim/<id>/statusdispositivo → brokersíonline, u offline vía Last Will
testmqtt/sim/<id>/telemetrydispositivo → brokernoLectura JSON
testmqtt/sim/<id>/cmdbackend → dispositivonoComando JSON, p. ej. {"interval": 2}
testmqtt/sim/<id>/cmd/ackdispositivo → backendnoConfirmación

Los client ID siguen el patrón sim-<run>-<index>, por ejemplo sim-3fa2c1-0042. Son únicos, legibles en los logs del broker y fáciles de filtrar. El generador de client ID y las buenas prácticas de client ID explican por qué importan estas propiedades.

El simulador

Instala la dependencia con pip install aiomqtt (versión 2.x, basada en paho-mqtt 2.x). Cada dispositivo es una corrutina con su propio aiomqtt.Client:

simulator.py
import asyncio, json, os, random, socket, sys, time, uuid
from collections import Counter
import aiomqtt

HOST, PORT = os.environ.get("MQTT_HOST", "broker.connectmq.com"), 1883
USER, PASS = os.environ.get("MQTT_USER"), os.environ.get("MQTT_PASS")
N_DEVICES = int(os.environ.get("DEVICES", 100))
RAMP_S = 60              # spread device boot over one minute
CRASH_PROB = 0.002       # chance of a power loss after each publish
RUN = uuid.uuid4().hex[:6]
stats = Counter()

class PowerLoss(Exception):
    pass

async def commands(client, base, state):
    try:
        async for msg in client.messages:
            try:
                cmd = dict(json.loads(msg.payload))
                if "interval" in cmd:
                    state["interval"] = max(1.0, float(cmd["interval"]))
            except (ValueError, TypeError):
                continue                                   # ignore malformed commands
            await client.publish(f"{base}/cmd/ack", json.dumps({"ok": True, **cmd}), qos=1)
            stats["commands"] += 1
    except aiomqtt.MqttError:
        pass                                               # connection lost; device() reconnects

async def device(i: int):
    dev_id = f"sim-{RUN}-{i:04d}"
    base = f"testmqtt/sim/{dev_id}"
    rng = random.Random(i)
    state = {"interval": rng.choice([5, 10, 30])}
    temp, boots, attempt = rng.uniform(19, 24), 0, 0
    await asyncio.sleep(rng.uniform(0, RAMP_S))          # staggered boot

    while True:
        try:
            will = aiomqtt.Will(f"{base}/status", b"offline", qos=1, retain=True)
            async with aiomqtt.Client(HOST, PORT, identifier=dev_id, username=USER,
                                      password=PASS, keepalive=30, will=will) as client:
                attempt, boots, started = 0, boots + 1, time.monotonic()
                stats["online"] += 1
                try:
                    await client.publish(f"{base}/status", b"online", qos=1, retain=True)
                    await client.subscribe(f"{base}/cmd", qos=1)
                    listener = asyncio.create_task(commands(client, base, state))
                    try:
                        while True:
                            temp += rng.gauss(0, 0.05)
                            reading = {"temp": round(temp, 2), "boots": boots,
                                       "uptime": int(time.monotonic() - started), "fw": "1.4.2"}
                            await client.publish(f"{base}/telemetry", json.dumps(reading), qos=1)
                            stats["published"] += 1
                            if rng.random() < CRASH_PROB:
                                # power loss: drop TCP without DISCONNECT so the broker fires the will
                                # (aiomqtt has no public API for this, so reach the paho client)
                                client._client.socket().shutdown(socket.SHUT_RDWR)
                                await asyncio.sleep(1)
                                raise PowerLoss
                            await asyncio.sleep(state["interval"] * rng.uniform(0.9, 1.1))
                    finally:
                        listener.cancel()
                finally:
                    stats["online"] -= 1
        except PowerLoss:
            stats["power_losses"] += 1
            await asyncio.sleep(rng.uniform(5, 30))       # time to reboot
        except aiomqtt.MqttError:
            stats["conn_errors"] += 1
            attempt += 1
            await asyncio.sleep(min(60, 2 ** attempt) * rng.uniform(0.5, 1.0))

async def report():
    while True:
        await asyncio.sleep(10)
        print(time.strftime("%H:%M:%S"), dict(stats), flush=True)

async def main():
    print(f"run {RUN}: starting {N_DEVICES} devices under testmqtt/sim/sim-{RUN}-*")
    await asyncio.gather(report(), *(device(i) for i in range(N_DEVICES)))

if __name__ == "__main__":
    if sys.platform == "win32":
        asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
    asyncio.run(main())

Algunos detalles hacen que este simulador sea fiable:

  • random.Random(i) por dispositivo — el dispositivo 42 siempre tiene el mismo intervalo, la misma temperatura inicial y el mismo patrón de fallos, así que un bug que veas una vez se puede reproducir.
  • Estado retenido con su Last Will correspondiente — el dispositivo publica online tras conectarse; si desaparece, el broker publica el will retenido offline. Lee Last Will and Testament en MQTT para saber exactamente cuándo se disparan los wills.
  • Reconexión con backoff exponencial y jitter — sin ella, 1.000 dispositivos que pierden el broker en el mismo instante vuelven todos en el mismo instante. Ese patrón, y cómo evitarlo, se explica en tormentas de reconexión MQTT.
  • Windows — allí aiomqtt necesita el event loop basado en selector, de ahí la línea de la política al final.

Ejecutar 1.000 dispositivos

Arrancar la flota
ulimit -n 4096                     # one socket per device, plus headroom
export MQTT_HOST=your-broker-host    # your own broker instance for the full fleet
export MQTT_USER=username MQTT_PASS=password
DEVICES=1000 python simulator.py

Cada diez segundos el simulador imprime una línea como {'online': 1000, 'published': 48213, 'power_losses': 3, ...}. Con los intervalos por defecto (5, 10 o 30 s por dispositivo), 1.000 dispositivos generan del orden de 100 mensajes por segundo: telemetría realista, no un benchmark. Para apretar más, acorta los intervalos o arranca más procesos con prefijos RUN distintos; para presión pura de conexiones, consulta la prueba de estrés de conexiones MQTT.

Verifica lo que ve el broker

Cuenta los dispositivos que el broker considera online en este momento; los mensajes de estado retenidos se entregan nada más suscribirte:

Contar dispositivos online
mosquitto_sub -h broker.connectmq.com -p 1883 -u "$MQTT_USER" -P "$MQTT_PASS" \
  -t 'testmqtt/sim/+/status' -v -W 5 | grep -c ' online$'

Mira cómo se disparan los wills en tiempo real suscribiéndote al mismo filtro en el cliente MQTT online y esperando una pérdida de alimentación: el estado del dispositivo cambia a offline y vuelve a online tras su reinicio simulado.

Prueba el canal de bajada enviando un comando a un dispositivo y esperando la confirmación:

Enviar un comando
mosquitto_sub -h broker.connectmq.com -u "$MQTT_USER" -P "$MQTT_PASS" \
  -t 'testmqtt/sim/sim-3fa2c1-0042/cmd/ack' -C 1 -v &
mosquitto_pub -h broker.connectmq.com -u "$MQTT_USER" -P "$MQTT_PASS" \
  -t 'testmqtt/sim/sim-3fa2c1-0042/cmd' -q 1 -m '{"interval": 2}'

Sustituye 3fa2c1 por el ID de ejecución que el simulador mostró al arrancar. Tras la confirmación, la telemetría de ese dispositivo debería llegar cada dos segundos.

Ampliar el simulador

  • Payloads realistas — sustituye el valor único de temperatura por el modelo de sensor del generador de datos de prueba MQTT, con ciclos diarios, deriva y codificación binaria.
  • TLS — pasa port=8883 y tls_params=aiomqtt.TLSParameters() a aiomqtt.Client para medir el coste extra de los handshakes TLS en el arranque.
  • MQTT 5 — añade protocol=aiomqtt.ProtocolVersion.V5 y propiedades de session expiry para probar sesiones persistentes y expiración de mensajes.
  • Tipos de dispositivo — ejecuta varias funciones corrutina (thermostat, meter, gateway) con topics y ritmos distintos desde el mismo main().
  • Reconexión masiva — pon CRASH_PROB a cero y reinicia el broker durante una ejecución para ver cómo se recupera toda la flota.

Mira cómo se conectan los dispositivos simulados

Un simulador convierte "creemos que escala" en un experimento que puedes repetir cada semana. Da a cada dispositivo virtual una identidad real, haz que falle como falla el hardware y verifica el resultado en el broker: así, los primeros mil dispositivos reales serán solo la repetición de una prueba que ya has superado.

Preguntas frecuentes

¿Cuántos dispositivos MQTT simulados puede ejecutar una sola máquina?

Con asyncio, un proceso Python ejecuta sin problemas desde cientos hasta unos pocos miles de dispositivos a ritmos de telemetría típicos, porque cada dispositivo es una corrutina y no un hilo. Los límites prácticos son el límite de descriptores de archivo (ulimit -n), los límites de conexiones del broker y la CPU dedicada a JSON y TLS. Por encima de eso, usa varios procesos o máquinas.

¿Cada dispositivo simulado debe tener su propia conexión MQTT?

Sí, si quieres probar el broker tal como lo usa una flota real. Una conexión por dispositivo ejercita la gestión de conexiones, el keep-alive, las sesiones, el Last Will y las ACL por cliente. Publicar en nombre de muchos dispositivos por una sola conexión solo prueba el throughput.

¿Cómo simulo que un dispositivo se queda sin alimentación?

Cierra la conexión TCP sin enviar DISCONNECT. El broker lo trata como una desconexión inesperada y publica el Last Will del dispositivo. Una desconexión limpia, en cambio, descarta el will, así que no prueba tu detección de dispositivos offline.