Tests de brokers

Créer un simulateur d'appareils MQTT en Python

Créez un simulateur d'appareils MQTT asyncio en Python : 1 000 appareils virtuels avec client ID uniques, Last Will, intervalles réalistes et coupures.

Mis à jour · 10 min de lecture

Créer un simulateur d'appareils MQTT en Python

Un simulateur d'appareils MQTT permet de tester un broker, un backend ou un dashboard face à une flotte qui n'existe pas encore. Toute l'astuce consiste à faire en sorte que chaque appareil virtuel se comporte comme un vrai : sa propre connexion et un client ID unique, un Last Will qui le marque hors ligne, une télémétrie à intervalles légèrement irréguliers, des commandes auxquelles il répond et, de temps en temps, une coupure de courant.

Ce guide construit un tel simulateur en environ 120 lignes de Python avec asyncio et aiomqtt, le pousse à 1 000 appareils et montre comment vérifier ce que voit le broker.

Ce que fait un appareil virtuel réaliste

ComportementPourquoi c'est important pour les tests
Connexion propre, client ID uniqueSollicite les sessions par client, les ACL et les limites de connexions. Des ID en double s'éjectent mutuellement.
Démarrage échelonnéLes vraies flottes ne se connectent pas à la même milliseconde — sauf quand le courant revient après une panne.
Statut online retenu + Last Will offlinePermet aux dashboards d'afficher la présence et vérifie que le broker déclenche les wills.
Intervalle de publication avec jitterÉvite les rafales parfaitement synchronisées que de vrais appareils ne produisent jamais.
Flotte hétérogèneCertains appareils publient toutes les 5 s, d'autres toutes les 30 s ; les payloads diffèrent.
Topic de commandesTeste le chemin descendant : backend → broker → appareil → acquittement.
Coupures d'alimentationL'appareil disparaît sans DISCONNECT, puis redémarre et se reconnecte avec backoff.

Organisation des topics

Chaque appareil possède un petit sous-arbre. Un préfixe par exécution sépare les tests lancés en parallèle :

TopicSensRetenuContenu
testmqtt/sim/<id>/statusappareil → brokerouionline, ou offline via le Last Will
testmqtt/sim/<id>/telemetryappareil → brokernonMesure JSON
testmqtt/sim/<id>/cmdbackend → appareilnonCommande JSON, ex. {"interval": 2}
testmqtt/sim/<id>/cmd/ackappareil → backendnonAcquittement

Les client ID suivent le modèle sim-<run>-<index>, par exemple sim-3fa2c1-0042. Ils sont uniques, lisibles dans les logs du broker et faciles à filtrer. Le générateur de client ID et les bonnes pratiques pour les client ID expliquent pourquoi ces propriétés comptent.

Le simulateur

Installez la dépendance avec pip install aiomqtt (version 2.x, basée sur paho-mqtt 2.x). Chaque appareil est une coroutine avec son propre 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())

Quelques détails rendent ce simulateur fiable :

  • random.Random(i) par appareil — l'appareil 42 a toujours le même intervalle, la même température de départ et le même schéma de pannes : un bug observé une fois peut être reproduit.
  • Statut retenu avec le Last Will correspondant — l'appareil publie online après sa connexion ; s'il disparaît, le broker publie le will retenu offline. Lisez Last Will and Testament dans MQTT pour savoir précisément quand les wills se déclenchent.
  • Reconnexion avec backoff exponentiel et jitter — sans cela, 1 000 appareils qui perdent le broker au même instant reviennent tous au même instant. Ce schéma, et la façon de l'éviter, est décrit dans les tempêtes de reconnexion MQTT.
  • Windows — aiomqtt y a besoin de la boucle d'événements à sélecteur, d'où la ligne de policy en bas du fichier.

Faire tourner 1 000 appareils

Démarrer la flotte
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

Toutes les dix secondes, le simulateur affiche une ligne comme {'online': 1000, 'published': 48213, 'power_losses': 3, ...}. Avec les intervalles par défaut (5, 10 ou 30 s par appareil), 1 000 appareils produisent de l'ordre de 100 messages par seconde : une télémétrie réaliste, pas un benchmark. Pour charger davantage, raccourcissez les intervalles ou lancez d'autres processus avec des préfixes RUN différents ; pour une pression purement liée aux connexions, voir le stress test de connexions MQTT.

Vérifier ce que voit le broker

Comptez les appareils que le broker considère actuellement en ligne — les messages de statut retenus sont livrés dès l'abonnement :

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

Regardez les wills se déclencher en temps réel en vous abonnant au même filtre dans le client MQTT en ligne et en attendant une coupure : le statut de l'appareil passe à offline, puis revient à online après son redémarrage simulé.

Testez le chemin descendant en envoyant une commande à un appareil et en attendant l'acquittement :

Envoyer une commande
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}'

Remplacez 3fa2c1 par l'identifiant d'exécution affiché par le simulateur au démarrage. Après l'acquittement, la télémétrie de cet appareil doit arriver toutes les deux secondes.

Étendre le simulateur

  • Payloads réalistes — remplacez la valeur de température unique par le modèle de capteur du générateur de données de test MQTT, avec cycle quotidien, dérive et encodage binaire.
  • TLS — passez port=8883 et tls_params=aiomqtt.TLSParameters() à aiomqtt.Client pour mesurer le surcoût des handshakes TLS au démarrage.
  • MQTT 5 — ajoutez protocol=aiomqtt.ProtocolVersion.V5 et des propriétés de session expiry pour tester les sessions persistantes et l'expiration des messages.
  • Types d'appareils — lancez plusieurs fonctions coroutines (thermostat, meter, gateway) avec des topics et des cadences différents depuis le même main().
  • Reconnexion massive — mettez CRASH_PROB à zéro et redémarrez le broker pendant une exécution pour voir comment toute la flotte se rétablit.

Regardez les appareils simulés se connecter

Un simulateur transforme « on pense que ça tient la charge » en une expérience que vous pouvez relancer chaque semaine. Donnez à chaque appareil virtuel une vraie identité, faites-le tomber en panne comme le fait le matériel et vérifiez le résultat sur le broker : les mille premiers appareils réels ne seront alors que la répétition d'un test déjà réussi.

Questions fréquentes

Combien d'appareils MQTT simulés une machine peut-elle faire tourner ?

Avec asyncio, un seul processus Python fait tourner sans difficulté de quelques centaines à quelques milliers d'appareils à des cadences de télémétrie classiques, car chaque appareil est une coroutine et non un thread. Les limites pratiques sont le nombre de descripteurs de fichiers (ulimit -n), les limites de connexions du broker et le CPU consommé par le JSON et le TLS. Au-delà, utilisez plusieurs processus ou plusieurs machines.

Chaque appareil simulé doit-il avoir sa propre connexion MQTT ?

Oui, si vous voulez tester le broker comme une vraie flotte l'utilise. Une connexion par appareil sollicite la gestion des connexions, le keep-alive, les sessions, le Last Will et les ACL par client. Publier pour de nombreux appareils sur une seule connexion ne teste que le débit.

Comment simuler une coupure d'alimentation d'un appareil ?

Fermez la connexion TCP sans envoyer de DISCONNECT. Le broker considère alors la déconnexion comme inattendue et publie le Last Will de l'appareil. Une déconnexion propre, au contraire, supprime le will et ne teste donc pas votre détection des appareils hors ligne.