all posts

esp32

ESP32 + MQTT Sparkplug B — proper industrial payloads, not raw JSON

Why Sparkplug B beats raw MQTT for industrial telemetry, the topic structure that matters, birth and death certificates explained, and a working ESP32 implementation that publishes to any Sparkplug-aware MQTT broker.

By Akshay Sarode· January 30, 2026· 11 min readesp32mqttsparkplugindustrial

ESP32 + MQTT Sparkplug B — proper industrial payloads, not raw JSON

Most ESP32 + MQTT tutorials look like this: connect to broker, publish JSON to home/livingroom/temperature, retain=true, done. That works for a hobby home-automation setup with three devices. It does not work when you have 200 ESP32 nodes across four sites and you need to know in real time which ones are alive, which ones rebooted, which ones disagree about what they're publishing, and which ones quietly fell off the network three hours ago.

That's what Eclipse Sparkplug B is for. Sparkplug is the open-spec, open-source, Cirrus Link-originated protocol that defines how MQTT should actually be used in an industrial context. It's been an Eclipse Foundation project since 2019, has a 3.0 specification published in 2023, and is the default payload format for Ignition by Inductive Automation, HiveMQ Edge, and a growing list of OT brokers.

This post is an opinionated walkthrough of how to publish Sparkplug B from an ESP32, why each piece matters, and what the production-grade pattern actually looks like.

Why bother — what raw MQTT misses

Three things, mainly:

1. State, not just data. Raw MQTT tells you values. Sparkplug tells you whether the publisher is alive. The "death certificate" mechanism (a Last Will and Testament) means the broker publishes a clear "this device is offline" message the second the TCP connection drops. With raw MQTT you can publish forever and a stale value sits there with retain=true looking healthy.

2. Schema without schema-registry overhead. Sparkplug uses Google Protobuf for the payload, with a defined field set. Each metric carries its own type, name, and timestamp. When a new device joins, it sends a "birth certificate" listing every metric it'll publish — so the consumer (Ignition, HiveMQ, your Sutrace tenant) auto-discovers the schema without anyone hand-writing a tag database.

3. Topic structure that doesn't drift. Raw MQTT topics are free-form, which means in any project larger than ten nodes you end up with factory1/line2/sensor3/temp, Factory1/Line-2/Sensor3/Temperature, and f1/l2/s3/t all coexisting because three different developers had three different conventions. Sparkplug enforces:

spBv1.0/[group_id]/[message_type]/[edge_node_id]/[device_id]

Where message_type is one of NBIRTH, NDEATH, DBIRTH, DDEATH, NDATA, DDATA, NCMD, DCMD, STATE. Once you adopt that, every consumer everywhere knows exactly what to expect.

The lifecycle

A Sparkplug edge node (your ESP32) has exactly four message types it cares about:

  • NBIRTH — "I'm online, here are all my metrics, here are their initial values." Sent once on connect.
  • NDEATH — "I'm gone." Pre-registered as the broker's Last Will, automatically published when the TCP connection drops.
  • NDATA — "Some metrics changed, here are the new values." Sent on change or on a timer.
  • NCMD — Inbound: "Set this metric to that value." This is how the dashboard writes back.

If the ESP32 has child devices (e.g., a downstream Modbus slave), the same set repeats with D (DBIRTH/DDEATH/DDATA/DCMD).

The broker maintains a sequence number seq from 0 to 255 per edge node. NBIRTH starts the sequence at 0. Every subsequent message increments. If the consumer sees a gap, it requests an NBIRTH-style rebirth via NCMD. This is how you detect lost messages in the wild without needing a separate health protocol.

Topic structure

For the Sutrace bench kit, our default topic shape is:

spBv1.0/<tenant>/NBIRTH/<device_id>
spBv1.0/<tenant>/NDATA/<device_id>
spBv1.0/<tenant>/NDEATH/<device_id>

Where <tenant> is your Sutrace tenant slug, <device_id> is the bench device claim ID (bench-001, chiller-3-room-12, etc.). The Sparkplug spec calls these group_id and edge_node_id. The mapping is intentional: one tenant = one group; one ESP32 = one edge node.

If your ESP32 is a Modbus master with downstream slaves (the RS485 walkthrough), each slave is a Sparkplug "device" under that node:

spBv1.0/<tenant>/DBIRTH/<edge>/<device>
spBv1.0/<tenant>/DDATA/<edge>/<device>
spBv1.0/<tenant>/DDEATH/<edge>/<device>

So an ESP32 polling four Modbus slaves publishes one NBIRTH for itself, then four DBIRTHs (one per slave), and each slave's data flows on its own DDATA topic.

ESP32 implementation

The two viable libraries on Arduino-ESP32 are tahu (the official Eclipse implementation, but the C version targets desktop, so on ESP32 you typically take just the protobuf payload code) and a roll-your-own using Nanopb for the protobuf encoding.

We use Nanopb because it's 6 KB of flash and has zero heap allocation. Here's the simplified NBIRTH publisher:

#include <PubSubClient.h>
#include <pb_encode.h>
#include "tahu.pb.h"   // generated from Sparkplug B .proto

const char* TENANT = "tenant-acme";
const char* EDGE   = "bench-001";

uint8_t seq = 0;
uint64_t bdSeq = 0;   // birth-death sequence; survives reboots in NVS

void publishNBirth(PubSubClient& mqtt) {
  org_eclipse_tahu_protobuf_Payload payload =
      org_eclipse_tahu_protobuf_Payload_init_zero;

  payload.has_timestamp = true;
  payload.timestamp = millisToEpochMillis();

  payload.has_seq = true;
  payload.seq = seq++;

  // bdSeq metric is mandatory in NBIRTH and NDEATH
  add_metric_long(&payload, "bdSeq", bdSeq);

  // Then every metric the device intends to publish
  add_metric_double(&payload, "temp_c",   21.4);
  add_metric_double(&payload, "rh_pct",   42.1);
  add_metric_double(&payload, "loop_ma",  12.3);

  uint8_t buf[512];
  pb_ostream_t s = pb_ostream_from_buffer(buf, sizeof(buf));
  pb_encode(&s, org_eclipse_tahu_protobuf_Payload_fields, &payload);

  char topic[128];
  snprintf(topic, sizeof(topic),
           "spBv1.0/%s/NBIRTH/%s", TENANT, EDGE);
  mqtt.publish(topic, buf, s.bytes_written);
}

The mandatory bits the spec requires: every NBIRTH includes a bdSeq metric (its value matches the value used in the corresponding NDEATH Last Will), the message has a timestamp field, and a seq field starting at 0. Every NDATA increments seq (wrapping at 256 back to 0).

The Last Will registration happens at connect time:

char willTopic[128];
snprintf(willTopic, sizeof(willTopic),
         "spBv1.0/%s/NDEATH/%s", TENANT, EDGE);

uint8_t willPayload[64];
pb_ostream_t ws = pb_ostream_from_buffer(willPayload, sizeof(willPayload));
org_eclipse_tahu_protobuf_Payload death =
    org_eclipse_tahu_protobuf_Payload_init_zero;
add_metric_long(&death, "bdSeq", bdSeq);
pb_encode(&ws, org_eclipse_tahu_protobuf_Payload_fields, &death);

mqtt.connect(EDGE, MQTT_USER, MQTT_PASS,
             willTopic, /*qos*/ 1, /*retain*/ false,
             (const char*)willPayload, ws.bytes_written);

The bdSeq in the will payload must match the bdSeq you'll publish in the next NBIRTH. The pattern is: load bdSeq from NVS, register the will with that value, connect, increment bdSeq, save to NVS, publish NBIRTH with the new value. This is what lets the broker detect "I never got an NDEATH but a new NBIRTH arrived" — meaning the device dropped without warning — and surface that as a fault.

Sparkplug-aware brokers

A vanilla Mosquitto will deliver Sparkplug payloads but doesn't understand them. To get the state-tracking benefits — automatic offline detection, sequence-gap detection, NCMD-driven rebirth — you need a Sparkplug-aware broker. The main options:

  • HiveMQ Edge — open-source community edition, Sparkplug-native, runs on a Raspberry Pi.
  • EMQX — Sparkplug plug-in available, multi-tenant capable.
  • Inductive Automation Ignition with the MQTT Engine module — full Ignition tag-tree integration; very expensive.
  • Sutrace's broker (mqtt.eu.sutrace.io) — Sparkplug-native, EU-resident, no install. The bench kit ships pre-configured for this.

If you're testing locally, HiveMQ Edge in Docker is the lowest-friction option:

docker run -d --name hivemq-edge -p 1883:1883 -p 8080:8080 \
    hivemq/hivemq-edge:latest

Then point your ESP32 at your-laptop-ip:1883 (no TLS for local testing) and watch the topics flow on the HiveMQ admin UI at port 8080.

Why we use Sparkplug instead of raw JSON for Sutrace

Three reasons that are specifically about running a multi-tenant industrial observability product:

  1. Auto-discovery. When a new ESP32 joins a customer's tenant, the NBIRTH tells our backend exactly what metrics to expect, with types and units, with no human config step. The dashboard shows the device with its full chart-set in seconds.

  2. State integrity. When the customer's WiFi drops, we publish a synthetic NDEATH (via the broker's LWT) and the dashboard greys out the device — instead of showing a 30-minute-old reading as if it were live.

  3. Re-birth on reconnect. If we restart our backend, the broker sends an NCMD "rebirth" to all connected nodes, and the entire tag tree reconstructs without any cold-start data loss. With raw MQTT you'd have to retain every value, which doesn't scale beyond a few thousand topics.

For a small home project you don't need any of that. For 200 ESP32s across four sites with SLAs and compliance obligations, you very much do.

External references