Broker Testing

Generate Realistic MQTT Test Data (Sensors, JSON, Binary)

Generate realistic MQTT test data: Python and Node.js scripts for sensor telemetry with daily cycles, noise and drift, encoded as JSON or compact binary.

Updated · 9 min read

Generate Realistic MQTT Test Data (Sensors, JSON, Binary)

To generate realistic MQTT test data, model each sensor as a baseline plus a daily cycle, measurement noise, slow drift and the occasional spike, then encode the reading the same way your devices do — JSON or a packed binary struct — and publish it on the same topics. That gives dashboards, rules and storage something believable to chew on, long before real hardware exists.

This guide builds that generator in Python, adds a binary encoding and a set of deliberately broken payloads for negative testing, and shows a compact Node.js version.

What makes sensor data look real

random.uniform(15, 30) produces values that jump around without any structure. Real sensors behave differently, and the differences are exactly what breaks downstream code:

PropertyReal-world exampleWhat it tests
BaselineA room sits around 21 °CDefault chart ranges, sane thresholds
Daily cycleWarmer in the afternoon, cooler at nightAggregation per hour/day, trend lines
Noise±0.1–0.2 °C jitter between samplesSmoothing, deadbands, change detection
DriftCalibration slowly wanders over weeksAnomaly detection, recalibration logic
SpikesA door opens, a sensor glitchesOutlier filters, alert de-duplication
QuantizationThe sensor reports in 0.1 °C or 0.5 % stepsEquality checks, rounding bugs
GapsReadings lost on a weak linkMissing-data handling, sequence checks
Clock jitterDevice timestamps are off by a few hundred msOrdering, time-window joins

A sensor model in Python

The model below keeps one random generator per device, so every run with the same seed produces exactly the same data. Drift is a mean-reverting random walk: it wanders but does not run away to infinity during a multi-day test.

sensors.py
import math, random
from dataclasses import dataclass

@dataclass
class Sensor:
    base: float            # typical value
    daily_amp: float       # amplitude of the day/night cycle
    noise: float           # standard deviation of measurement noise
    drift_step: float      # random-walk step per sample
    lo: float              # physical lower limit
    hi: float              # physical upper limit
    resolution: float      # quantization step, e.g. 0.1
    rng: random.Random
    spike_prob: float = 0.002
    drift: float = 0.0

    def read(self, t: float) -> float:
        # sine wave peaking at 15:00 UTC, lowest at 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),
    }

Humidity uses a negative amplitude because relative humidity usually falls as a room warms up — a small detail, but it means correlations in your analytics behave like the real thing.

Publishing JSON telemetry

The publisher creates a handful of devices, produces one reading per device per interval, drops about 1 % of readings to simulate a lossy link, and adds a little clock jitter to the timestamps. SPEEDUP compresses time so a full day/night cycle passes in a few minutes during development.

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: one simulated day every 4 minutes

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:                      # lost reading
            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)

Watch the stream with mosquitto_sub -h broker.connectmq.com -t 'testmqtt/datagen/+/telemetry' -v, or open the online MQTT client and subscribe to testmqtt/datagen/#. Field naming and units are worth getting right before devices ship; MQTT payload design best practices covers the trade-offs.

Binary encoding with struct

Many constrained devices send packed binary instead of JSON. The same reading fits in 14 bytes instead of about 90:

FieldTypeEncoding
versionuint8Format version, start at 1
tsuint32Unix time in seconds
sequint16Wraps at 65,535
temperatureint16°C × 100
humidityuint16% × 100
battery_mvuint16Millivolts
flagsuint8Bit field (low battery, sensor error…)
binary.py
import math, struct

FMT = "<BIHhHHB"            # little-endian, no 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())

In datagen.py, publish encode(reading) to a sibling topic such as testmqtt/datagen/sim-001/bin and check it on the wire with mosquitto_sub -t 'testmqtt/datagen/+/bin' -F '%t %x', which prints each payload as hex. Always state the byte order (< here): an ESP32 and most ARM chips are little-endian, but a decoder that assumes network byte order will silently produce nonsense. If you want a self-describing binary format instead of a hand-made struct, compare CBOR and MessagePack.

The MQTT packet size calculator shows what the two encodings cost per message once the fixed header and topic are included.

Broken payloads for negative testing

Realistic data tests the happy path. Consumers also need to survive the payloads that real fleets eventually send — a firmware bug, a truncated message, the wrong encoding. Publish a set of known-bad payloads and verify that your subscriber logs them and keeps running:

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}',          # Python's json accepts it, JSON.parse does not
    "huge-number": b'{"temperature": 1e999}',
    "not-utf8": '{"site": "café"}'.encode("latin-1"),
    "wrong-format": bytes.fromhex("01a0e4f1680a2a005c08"),   # binary where JSON is expected
    "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()

Each case maps to a real failure mode: the parser throws, a string sneaks into a numeric column, NaN breaks a database insert, or a large payload exceeds the broker's maximum packet size. MQTT JSON parse errors explains how to handle each one on the consumer side.

The same idea in Node.js

With MQTT.js 5 the generator is a few lines. Box–Muller turns Math.random() into Gaussian noise:

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);

Synthetic data vs recorded data

Synthetic data is ideal early on and for scenarios you cannot easily produce, such as 10,000 devices or a sensor failing at 3 a.m. Once a pilot fleet is running, record its real traffic and replay it as well — MQTT replay testing shows how. The best test suites use both: recorded data for realism, generated data for scale and edge cases. To turn this generator into many independent virtual devices with their own connections and Last Will, see build an MQTT device simulator in Python.

Watch generated telemetry live

A good test-data generator costs an afternoon and pays back on every dashboard, rule and database schema you build. Model the signal honestly, seed it, encode it exactly like the device, and keep a folder of broken payloads next to it.

Frequently asked questions

Why not just publish random numbers as test data?

Uniform random values have no trend, no daily cycle and no correlation between samples, so they never trigger the logic you actually need to test: thresholds, smoothing, anomaly detection, deadbands or chart scaling. A model of baseline, cycle, noise, drift and occasional spikes looks like a real sensor and exercises that logic.

Should MQTT test data be JSON or binary?

Use the format your devices really send. JSON is easiest to read and debug; a packed binary struct is several times smaller and is common on constrained or cellular devices. A good generator can produce both from the same reading so you can test both decoders.

How do I make generated test data reproducible?

Seed the random number generator per device, for example with the device index. The same seed produces the same sequence of readings, so a failing test can be replayed exactly and two runs of a benchmark compare like with like.