Broker-Tests

MQTT-Gerätesimulator in Python bauen

Baue einen asyncio-MQTT-Gerätesimulator in Python: 1.000 virtuelle Geräte mit eindeutigen Client-IDs, Last Will, realistischen Intervallen und Stromausfällen.

Aktualisiert · 10 Min. Lesezeit

MQTT-Gerätesimulator in Python bauen

Mit einem MQTT-Gerätesimulator testest du einen Broker, ein Backend oder ein Dashboard gegen eine Flotte, die es noch nicht gibt. Der Trick: Jedes virtuelle Gerät muss sich wie ein echtes verhalten – eigene Verbindung und eindeutige Client-ID, ein Last Will, das es als offline markiert, Telemetrie in leicht unregelmäßigen Abständen, Befehle, auf die es reagiert, und ab und zu ein Stromausfall.

Dieser Leitfaden baut einen solchen Simulator in rund 120 Zeilen Python mit asyncio und aiomqtt, skaliert ihn auf 1.000 Geräte und zeigt, wie du prüfst, was der Broker sieht.

Was ein realistisches virtuelles Gerät tut

VerhaltenWarum es beim Testen wichtig ist
Eigene Verbindung, eindeutige Client-IDTestet Sessions pro Client, ACLs und Verbindungslimits. Doppelte IDs werfen sich gegenseitig raus.
Gestaffelter StartEchte Flotten verbinden sich nicht in derselben Millisekunde – außer wenn nach einem Ausfall der Strom zurückkommt.
Retained-Status online + Last Will offlineDashboards können Präsenz anzeigen, und du testest, ob der Broker Wills auslöst.
Publish-Intervall mit JitterVermeidet perfekt synchrone Bursts, die echte Geräte nie erzeugen.
Heterogene FlotteManche Geräte melden alle 5 s, andere alle 30 s; die Payloads unterscheiden sich.
Befehls-TopicTestet den Rückkanal: Backend → Broker → Gerät → Acknowledgement.
StromausfälleDas Gerät verschwindet ohne DISCONNECT, startet neu und verbindet sich mit Backoff wieder.

Topic-Struktur

Jedes Gerät besitzt einen kleinen Teilbaum. Ein Präfix pro Lauf hält parallele Testläufe auseinander:

TopicRichtungRetainedInhalt
testmqtt/sim/<id>/statusGerät → Brokerjaonline, oder offline per Last Will
testmqtt/sim/<id>/telemetryGerät → BrokerneinJSON-Messwert
testmqtt/sim/<id>/cmdBackend → GerätneinJSON-Befehl, z. B. {"interval": 2}
testmqtt/sim/<id>/cmd/ackGerät → BackendneinBestätigung

Client-IDs folgen dem Muster sim-<run>-<index>, zum Beispiel sim-3fa2c1-0042. Sie sind eindeutig, in Broker-Logs lesbar und leicht zu filtern. Der Client-ID-Generator und die Best Practices für Client-IDs erklären, warum diese Eigenschaften wichtig sind.

Der Simulator

Installiere die Abhängigkeit mit pip install aiomqtt (Version 2.x, basiert auf paho-mqtt 2.x). Jedes Gerät ist eine Coroutine mit eigenem 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())

Einige Details machen diesen Simulator vertrauenswürdig:

  • random.Random(i) pro Gerät – Gerät 42 hat immer dasselbe Intervall, dieselbe Starttemperatur und dasselbe Ausfallmuster; ein Bug, den du einmal siehst, lässt sich also reproduzieren.
  • Retained-Status mit passendem Last Will – das Gerät publiziert nach dem Verbinden online; verschwindet es, publiziert der Broker das retained Will offline. In MQTT Last Will and Testament steht genau, wann Wills ausgelöst werden.
  • Reconnect mit exponentiellem Backoff und Jitter – ohne das kommen 1.000 Geräte, die den Broker im selben Moment verlieren, auch im selben Moment zurück. Dieses Muster und wie du es vermeidest, beschreibt MQTT-Reconnect-Stürme.
  • Windows – dort braucht aiomqtt die Selector-Event-Loop, deshalb die Policy-Zeile ganz unten.

1.000 Geräte betreiben

Flotte starten
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

Alle zehn Sekunden gibt der Simulator eine Zeile wie {'online': 1000, 'published': 48213, 'power_losses': 3, ...} aus. Mit den Standardintervallen (5, 10 oder 30 s pro Gerät) erzeugen 1.000 Geräte größenordnungsmäßig 100 Nachrichten pro Sekunde – realistische Telemetrie, kein Benchmark. Für mehr Druck verkürzt du die Intervalle oder startest weitere Prozesse mit anderem RUN-Präfix; für reinen Verbindungsdruck siehe den MQTT-Verbindungs-Stresstest.

Prüfen, was der Broker sieht

Zähle die Geräte, die der Broker aktuell als online meldet – die Retained-Statusnachrichten werden direkt beim Abonnieren zugestellt:

Online-Geräte zählen
mosquitto_sub -h broker.connectmq.com -p 1883 -u "$MQTT_USER" -P "$MQTT_PASS" \
  -t 'testmqtt/sim/+/status' -v -W 5 | grep -c ' online$'

Beobachte das Auslösen der Wills in Echtzeit, indem du denselben Filter im Online-MQTT-Client abonnierst und auf einen Stromausfall wartest: Der Status des Geräts springt auf offline und nach dem simulierten Neustart wieder auf online.

Teste den Rückkanal, indem du einem Gerät einen Befehl schickst und auf die Bestätigung wartest:

Befehl senden
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}'

Ersetze 3fa2c1 durch die Run-ID, die der Simulator beim Start ausgegeben hat. Nach der Bestätigung sollte die Telemetrie dieses Geräts alle zwei Sekunden eintreffen.

Den Simulator erweitern

  • Realistische Payloads – ersetze den einzelnen Temperaturwert durch das Sensormodell aus dem MQTT-Testdatengenerator, inklusive Tageszyklus, Drift und Binärkodierung.
  • TLS – übergib port=8883 und tls_params=aiomqtt.TLSParameters() an aiomqtt.Client, um die Zusatzkosten der TLS-Handshakes beim Start zu messen.
  • MQTT 5 – ergänze protocol=aiomqtt.ProtocolVersion.V5 und Session-Expiry-Properties, um persistente Sessions und Message Expiry zu testen.
  • Gerätetypen – starte aus demselben main() mehrere Coroutine-Funktionen (thermostat, meter, gateway) mit unterschiedlichen Topics und Raten.
  • Massen-Reconnect – setze CRASH_PROB auf null und starte den Broker während eines Laufs neu, um zu sehen, wie sich die ganze Flotte erholt.

Simulierte Geräte online gehen sehen

Ein Simulator macht aus „wir glauben, es skaliert" ein Experiment, das du jede Woche wiederholen kannst. Gib jedem virtuellen Gerät eine echte Identität, lass es so ausfallen, wie Hardware ausfällt, und prüfe das Ergebnis am Broker – dann sind die ersten tausend echten Geräte nur die Wiederholung eines Tests, den du schon bestanden hast.

Häufig gestellte Fragen

Wie viele simulierte MQTT-Geräte schafft eine Maschine?

Mit asyncio betreibt ein Python-Prozess problemlos Hunderte bis einige Tausend Geräte bei typischen Telemetrieraten, weil jedes Gerät eine Coroutine und kein Thread ist. Praktische Grenzen sind das Dateideskriptor-Limit (ulimit -n), die Verbindungslimits des Brokers und die CPU für JSON und TLS. Darüber hinaus nutzt du mehrere Prozesse oder Maschinen.

Sollte jedes simulierte Gerät eine eigene MQTT-Verbindung haben?

Ja, wenn du den Broker so testen willst, wie eine echte Flotte ihn nutzt. Eine Verbindung pro Gerät testet Verbindungsverwaltung, Keep-Alive, Sessions, Last Will und ACLs pro Client. Für viele Geräte über eine einzige Verbindung zu publizieren testet nur den Durchsatz.

Wie simuliere ich einen Stromausfall am Gerät?

Schließe die TCP-Verbindung, ohne DISCONNECT zu senden. Der Broker wertet das als unerwarteten Verbindungsabbruch und publiziert das Last Will des Geräts. Ein sauberer Disconnect verwirft das Will dagegen und testet deine Offline-Erkennung deshalb nicht.