Broker-Tests

Realistische MQTT-Testdaten erzeugen (Sensoren, JSON, Binär)

Realistische MQTT-Testdaten erzeugen: Python- und Node.js-Skripte für Sensortelemetrie mit Tageszyklus, Rauschen und Drift, als JSON oder kompakt binär.

Aktualisiert · 9 Min. Lesezeit

Realistische MQTT-Testdaten erzeugen (Sensoren, JSON, Binär)

Um realistische MQTT-Testdaten zu erzeugen, modellierst du jeden Sensor als Grundwert plus Tageszyklus, Messrauschen, langsame Drift und gelegentliche Ausreißer, kodierst den Messwert genau wie deine Geräte – JSON oder ein gepacktes Binär-Struct – und publizierst ihn auf denselben Topics. So bekommen Dashboards, Regeln und Speicher etwas Glaubwürdiges zu verarbeiten, lange bevor echte Hardware existiert.

Dieser Leitfaden baut den Generator in Python, ergänzt eine Binärkodierung und eine Sammlung absichtlich kaputter Payloads für Negativtests und zeigt eine kompakte Node.js-Variante.

Was Sensordaten realistisch macht

random.uniform(15, 30) liefert Werte, die ohne jede Struktur springen. Echte Sensoren verhalten sich anders – und genau diese Unterschiede bringen nachgelagerten Code zum Scheitern:

EigenschaftBeispiel aus der PraxisWas damit getestet wird
GrundwertEin Raum liegt bei etwa 21 °CStandard-Diagrammbereiche, sinnvolle Schwellwerte
TageszyklusNachmittags wärmer, nachts kühlerAggregation pro Stunde/Tag, Trendlinien
Rauschen±0,1–0,2 °C Schwankung zwischen MesswertenGlättung, Totbänder, Änderungserkennung
DriftDie Kalibrierung wandert über WochenAnomalieerkennung, Rekalibrierungslogik
AusreißerEine Tür geht auf, der Sensor hat einen AussetzerAusreißerfilter, Deduplizierung von Alarmen
QuantisierungDer Sensor meldet in Schritten von 0,1 °C oder 0,5 %Gleichheitsvergleiche, Rundungsfehler
LückenMesswerte gehen auf einer schwachen Verbindung verlorenUmgang mit fehlenden Daten, Sequenzprüfung
UhrabweichungGeräte-Zeitstempel liegen ein paar hundert ms danebenSortierung, Joins über Zeitfenster

Ein Sensormodell in Python

Das Modell unten nutzt pro Gerät einen eigenen Zufallsgenerator; jeder Lauf mit demselben Seed liefert also exakt dieselben Daten. Die Drift ist ein Random Walk mit Rückkehr zum Mittelwert: Sie wandert, läuft aber in einem mehrtägigen Test nicht ins Unendliche davon.

sensors.py
import math, random
from dataclasses import dataclass

@dataclass
class Sensor:
    base: float            # typischer Wert
    daily_amp: float       # Amplitude des Tag-Nacht-Zyklus
    noise: float           # Standardabweichung des Messrauschens
    drift_step: float      # Random-Walk-Schritt pro Messwert
    lo: float              # physikalische Untergrenze
    hi: float              # physikalische Obergrenze
    resolution: float      # Quantisierungsschritt, z. B. 0.1
    rng: random.Random
    spike_prob: float = 0.002
    drift: float = 0.0

    def read(self, t: float) -> float:
        # Sinuswelle mit Maximum um 15:00 UTC und Minimum um 03:00 UTC
        day = math.sin(2 * math.pi * ((t % 86400) / 86400 - 0.375))
        self.drift = 0.999 * self.drift + self.rng.gauss(0, self.drift_step)
        v = self.base + self.daily_amp * day + self.drift + self.rng.gauss(0, self.noise)
        if self.rng.random() < self.spike_prob:
            v += self.rng.choice((-1, 1)) * (self.hi - self.lo) * 0.2
        v = min(max(v, self.lo), self.hi)
        return round(round(v / self.resolution) * self.resolution, 3)

def make_device(seed: int) -> dict:
    rng = random.Random(seed)
    return {
        "rng": rng,
        "battery_mv": rng.uniform(4000, 4200),
        "temperature": Sensor(rng.uniform(19, 24), 2.5, 0.15, 0.02, -40, 85, 0.1, rng),
        "humidity": Sensor(rng.uniform(40, 55), -6.0, 0.8, 0.05, 0, 100, 0.5, rng),
    }

Die Luftfeuchtigkeit hat eine negative Amplitude, weil die relative Feuchte meist sinkt, wenn sich ein Raum erwärmt – ein kleines Detail, aber dadurch verhalten sich Korrelationen in deiner Auswertung wie in der Realität.

JSON-Telemetrie publizieren

Der Publisher legt ein paar Geräte an, erzeugt pro Gerät und Intervall einen Messwert, verwirft etwa 1 % der Werte, um eine verlustbehaftete Verbindung zu simulieren, und versieht die Zeitstempel mit etwas Uhrabweichung. SPEEDUP staucht die Zeit, sodass während der Entwicklung ein ganzer Tag-Nacht-Zyklus in wenigen Minuten vergeht.

datagen.py
import json, os, time
import paho.mqtt.client as mqtt
from sensors import make_device

HOST, PORT = "broker.connectmq.com", 1883
N_DEVICES, INTERVAL, SPEEDUP = 5, 2.0, 360   # 360x: ein simulierter Tag alle 4 Minuten

devices = {f"sim-{i:03d}": make_device(seed=i) for i in range(N_DEVICES)}

client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=f"datagen-{os.getpid()}")
client.username_pw_set(os.environ.get("MQTT_USER"), os.environ.get("MQTT_PASS"))
client.connect(HOST, PORT)
client.loop_start()

start, seq = time.time(), 0
while True:
    sim_t = start + (time.time() - start) * SPEEDUP
    for dev_id, d in devices.items():
        rng = d["rng"]
        if rng.random() < 0.01:                      # verlorener Messwert
            continue
        d["battery_mv"] -= rng.uniform(0, 0.05)
        reading = {
            "ts": int((sim_t + rng.uniform(-0.3, 0.3)) * 1000),
            "seq": seq,
            "temperature": d["temperature"].read(sim_t),
            "humidity": d["humidity"].read(sim_t),
            "battery_mv": int(d["battery_mv"]),
        }
        client.publish(f"testmqtt/datagen/{dev_id}/telemetry", json.dumps(reading), qos=1)
    seq += 1
    time.sleep(INTERVAL)

Beobachte den Datenstrom mit mosquitto_sub -h broker.connectmq.com -t 'testmqtt/datagen/+/telemetry' -v oder öffne den Online-MQTT-Client und abonniere testmqtt/datagen/#. Feldnamen und Einheiten solltest du festlegen, bevor die Geräte ausgeliefert werden; Best Practices für MQTT-Payload-Design erklärt die Abwägungen.

Binärkodierung mit struct

Viele ressourcenarme Geräte senden gepackte Binärdaten statt JSON. Derselbe Messwert passt in 14 Bytes statt in rund 90:

FeldTypKodierung
versionuint8Formatversion, beginnt bei 1
tsuint32Unix-Zeit in Sekunden
sequint16Läuft nach 65.535 über
temperatureint16°C × 100
humidityuint16% × 100
battery_mvuint16Millivolt
flagsuint8Bitfeld (Akku schwach, Sensorfehler…)
binary.py
import math, struct

FMT = "<BIHhHHB"            # Little-Endian, ohne Padding: 14 Bytes

def encode(r: dict, flags: int = 0) -> bytes:
    return struct.pack(FMT, 1, r["ts"] // 1000, r["seq"] & 0xFFFF,
                       round(r["temperature"] * 100), round(r["humidity"] * 100),
                       r["battery_mv"], flags)

def decode(b: bytes) -> dict:
    v, ts, seq, t, h, bat, flags = struct.unpack(FMT, b)
    return {"v": v, "ts": ts, "seq": seq, "temperature": t / 100,
            "humidity": h / 100, "battery_mv": bat, "flags": flags}

r = {"ts": 1760700000123, "seq": 42, "temperature": 21.4, "humidity": 47.5, "battery_mv": 4093}
packed = encode(r)
assert len(packed) == struct.calcsize(FMT) == 14
assert math.isclose(decode(packed)["temperature"], r["temperature"])
print(packed.hex())

Publiziere in datagen.py zusätzlich encode(reading) auf ein Nachbar-Topic wie testmqtt/datagen/sim-001/bin und prüfe es auf der Leitung mit mosquitto_sub -t 'testmqtt/datagen/+/bin' -F '%t %x', das jedes Payload als Hex ausgibt. Gib die Byte-Reihenfolge immer explizit an (< hier): Ein ESP32 und die meisten ARM-Chips sind Little-Endian, aber ein Decoder, der Network Byte Order annimmt, produziert stillschweigend Unsinn. Wenn du lieber ein selbstbeschreibendes Binärformat statt eines handgebauten Structs willst, vergleiche CBOR und MessagePack.

Der MQTT-Paketgrößen-Rechner zeigt, was beide Kodierungen pro Nachricht inklusive Fixed Header und Topic kosten.

Kaputte Payloads für Negativtests

Realistische Daten testen den Normalfall. Consumer müssen aber auch die Payloads überstehen, die echte Flotten irgendwann senden – ein Firmware-Bug, eine abgeschnittene Nachricht, die falsche Kodierung. Publiziere eine Reihe bekannt fehlerhafter Payloads und prüfe, ob dein Subscriber sie protokolliert und weiterläuft:

bad_payloads.py
import json, os
import paho.mqtt.client as mqtt

BAD = {
    "empty": b"",
    "truncated": b'{"temperature": 21.',
    "string-number": b'{"temperature": "21.4"}',
    "nan": b'{"temperature": NaN}',          # Pythons json akzeptiert es, JSON.parse nicht
    "huge-number": b'{"temperature": 1e999}',
    "not-utf8": '{"site": "café"}'.encode("latin-1"),
    "wrong-format": bytes.fromhex("01a0e4f1680a2a005c08"),   # Binärdaten, wo JSON erwartet wird
    "oversized": json.dumps({"temperature": 21.4, "pad": "x" * 200_000}).encode(),
}

c = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
c.username_pw_set(os.environ.get("MQTT_USER"), os.environ.get("MQTT_PASS"))
c.connect("broker.connectmq.com", 1883)
c.loop_start()
for name, payload in BAD.items():
    c.publish("testmqtt/datagen/sim-bad/telemetry", payload, qos=1).wait_for_publish()
    print("sent", name, len(payload), "bytes")
c.loop_stop()
c.disconnect()

Jeder Fall entspricht einem echten Fehlerbild: Der Parser wirft eine Exception, ein String rutscht in eine numerische Spalte, NaN lässt einen Datenbank-Insert scheitern, oder ein großes Payload überschreitet die maximale Paketgröße des Brokers. MQTT-JSON-Parse-Fehler zeigt, wie du jeden Fall im Consumer behandelst.

Dieselbe Idee in Node.js

Mit MQTT.js 5 ist der Generator in wenigen Zeilen geschrieben. Box–Muller macht aus Math.random() gaußsches Rauschen:

datagen.mjs
import mqtt from "mqtt";

const client = await mqtt.connectAsync("mqtt://broker.connectmq.com:1883", {
  username: process.env.MQTT_USER,
  password: process.env.MQTT_PASS,
});

const gauss = () => Math.sqrt(-2 * Math.log(1 - Math.random())) * Math.cos(2 * Math.PI * Math.random());
let drift = 0, seq = 0;

setInterval(async () => {
  const t = Date.now() / 1000;
  drift = 0.999 * drift + gauss() * 0.02;
  const day = Math.sin(2 * Math.PI * ((t % 86400) / 86400 - 0.375));
  const temperature = Math.round((21 + 2.5 * day + drift + gauss() * 0.15) * 10) / 10;
  const payload = JSON.stringify({ ts: Date.now(), seq: seq++, temperature });
  await client.publishAsync("testmqtt/datagen/js-001/telemetry", payload, { qos: 1 });
}, 2000);

Synthetische vs. aufgezeichnete Daten

Synthetische Daten sind ideal am Anfang und für Szenarien, die sich schwer herstellen lassen, etwa 10.000 Geräte oder ein Sensor, der um 3 Uhr nachts ausfällt. Sobald eine Pilotflotte läuft, zeichne ihren echten Traffic auf und spiele ihn ebenfalls ab – MQTT-Replay-Tests zeigt, wie. Die besten Testsuiten nutzen beides: aufgezeichnete Daten für Realismus, generierte Daten für Skalierung und Grenzfälle. Um aus diesem Generator viele unabhängige virtuelle Geräte mit eigener Verbindung und eigenem Last Will zu machen, siehe MQTT-Gerätesimulator in Python bauen.

Generierte Telemetrie live ansehen

Ein guter Testdatengenerator kostet einen Nachmittag und zahlt sich bei jedem Dashboard, jeder Regel und jedem Datenbankschema aus, das du baust. Modelliere das Signal ehrlich, setze Seeds, kodiere genau wie das Gerät und leg einen Ordner mit kaputten Payloads daneben.

Häufig gestellte Fragen

Warum nicht einfach Zufallszahlen als Testdaten publizieren?

Gleichverteilte Zufallswerte haben keinen Trend, keinen Tageszyklus und keine Korrelation zwischen Messwerten. Sie lösen deshalb nie die Logik aus, die du eigentlich testen willst: Schwellwerte, Glättung, Anomalieerkennung, Totbänder oder Diagrammskalierung. Ein Modell aus Grundwert, Zyklus, Rauschen, Drift und gelegentlichen Ausreißern sieht aus wie ein echter Sensor und testet genau diese Logik.

Sollten MQTT-Testdaten JSON oder binär sein?

Nimm das Format, das deine Geräte tatsächlich senden. JSON ist am leichtesten zu lesen und zu debuggen; ein gepacktes Binär-Struct ist um ein Vielfaches kleiner und auf ressourcenarmen oder Mobilfunk-Geräten üblich. Ein guter Generator erzeugt beides aus demselben Messwert, damit du beide Decoder testen kannst.

Wie mache ich generierte Testdaten reproduzierbar?

Gib dem Zufallsgenerator jedes Geräts einen Seed, zum Beispiel den Geräteindex. Derselbe Seed liefert dieselbe Folge von Messwerten, sodass ein fehlgeschlagener Test exakt wiederholt werden kann und zwei Benchmark-Läufe Gleiches mit Gleichem vergleichen.