Designing Sparkplug B Topic Namespaces for Factory Telemetry
Sparkplug B is not merely a payload format layered onto MQTT — it is a complete specification for the topic namespace itself, the message lifecycle, and how metrics get identified without repeating their full name on every publish. This page sits under MQTT Topic Hierarchies and covers the part teams most often get wrong when adopting Sparkplug: treating the group_id/edge_node_id/device_id triad as an arbitrary label scheme instead of the strict lifecycle contract the specification actually defines. Get the namespace design right and a SCADA host can auto-discover every device on the network the moment it connects; get it wrong and you end up with a namespace that looks Sparkplug-compliant but silently breaks the birth/death state machine that makes Sparkplug worth adopting over a hand-rolled topic tree.
The specification, maintained by the Eclipse Tahu project under the Eclipse Foundation, defines Sparkplug B’s payload as a fixed Protocol Buffers schema — the same binary encoding discussed in Payload Serialization Formats — wrapped in a topic namespace and lifecycle that solve problems JSON-over-MQTT and raw Protobuf-over-MQTT both leave to the implementer: how does a new subscriber learn the current state of every device without waiting an arbitrary amount of time for its next publish, and how does the network detect that an edge node has gone offline uncleanly.
The namespace grammar Permalink to this section
Sparkplug B’s topic namespace is fixed by the specification, not left to per-plant convention the way a raw MQTT hierarchy is: spBv1.0/group_id/message_type/edge_node_id/[device_id]. The device_id segment is present only for device-scoped message types (DBIRTH, DDATA, DDEATH); node-scoped types (NBIRTH, NDATA, NDEATH) omit it. This is the detail that most breaks teams migrating from a ISA-95-aligned topic tree where every level is a fixed physical-hierarchy segment — Sparkplug’s message_type sits in the middle of the path, not appended at the end, and it is drawn from a fixed enumeration rather than an open vocabulary.
from __future__ import annotations
import re
from dataclasses import dataclass
from enum import Enum
_SEGMENT = re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
class MessageType(str, Enum):
NBIRTH = "NBIRTH" # edge node birth certificate
NDATA = "NDATA" # edge node data (node-scoped metrics)
NDEATH = "NDEATH" # edge node death certificate (via MQTT Will)
DBIRTH = "DBIRTH" # device birth certificate
DDATA = "DDATA" # device data
DDEATH = "DDEATH" # device death certificate
NCMD = "NCMD" # command to an edge node
DCMD = "DCMD" # command to a device
STATE = "STATE" # primary application/host online status
_DEVICE_SCOPED = {MessageType.DBIRTH, MessageType.DDATA, MessageType.DDEATH, MessageType.DCMD}
@dataclass(frozen=True, slots=True)
class SparkplugTopic:
group_id: str
message_type: MessageType
edge_node_id: str
device_id: str | None = None
def __post_init__(self) -> None:
for value in (self.group_id, self.edge_node_id):
if not _SEGMENT.match(value):
raise ValueError(f"invalid Sparkplug segment: {value!r}")
is_device_scoped = self.message_type in _DEVICE_SCOPED
if is_device_scoped and not self.device_id:
raise ValueError(f"{self.message_type} requires a device_id")
if not is_device_scoped and self.device_id:
raise ValueError(f"{self.message_type} must not carry a device_id")
def topic(self) -> str:
parts = ["spBv1.0", self.group_id, self.message_type.value, self.edge_node_id]
if self.device_id:
parts.append(self.device_id)
return "/".join(parts)
ndata = SparkplugTopic("plant_eu", MessageType.NDATA, "gateway_04")
assert ndata.topic() == "spBv1.0/plant_eu/NDATA/gateway_04"
ddata = SparkplugTopic("plant_eu", MessageType.DDATA, "gateway_04", "spindle_07")
assert ddata.topic() == "spBv1.0/plant_eu/DDATA/gateway_04/spindle_07"
group_id should map to a meaningful physical or organizational boundary — a plant or a production area — because it is the top-level unit a SCADA host subscribes to as a whole (spBv1.0/plant_eu/#). Keep edge_node_id stable across restarts; it identifies the physical gateway (or its MQTT client), and every device beneath it inherits its lifecycle. Reusing an edge_node_id for a different physical gateway after decommissioning one confuses any host that cached the old node’s birth certificate.
The birth/death lifecycle Permalink to this section
Sparkplug’s central innovation over a plain MQTT topic tree is that state is never assumed — it is established once via a birth certificate and invalidated deterministically via a death certificate, so a subscriber joining mid-session can reconstruct full current state without waiting for the next data publish on every metric.
NBIRTH is published immediately after an edge node connects (retain=false, unlike the retained-state anti-pattern common in ad-hoc MQTT designs) and enumerates every metric the node will report, each with its full name, its Sparkplug datatype, its initial value, and — critically — the alias it will use for all subsequent NDATA publishes. DBIRTH does the same for each device beneath the node, published after NBIRTH and before that device’s first DDATA.
NDEATH is not published by the edge node during normal operation — it is registered as the MQTT Will message at connection time, so the broker publishes it automatically if the node disconnects uncleanly (power loss, network partition, crash) without a graceful DISCONNECT. This is what makes Sparkplug’s offline detection reliable in a way a heartbeat-polling scheme is not: the broker, not the (possibly already-dead) client, delivers the death notice.
import time
from dataclasses import dataclass, field
import paho.mqtt.client as mqtt
# In production, encode/decode NBIRTH/DBIRTH/NDATA payloads with the
# Sparkplug B protobuf schema (org.eclipse.tahu.protobuf.Payload) via
# the eclipse-tahu-python-client or an equivalent compiled binding.
from tahu_client import SparkplugPayload, Metric, DataType # illustrative import
@dataclass
class EdgeNodeSession:
group_id: str
edge_node_id: str
client: mqtt.Client
bd_seq: int = 0 # birth/death sequence, increments each reconnect
alias_map: dict[str, int] = field(default_factory=dict)
next_alias: int = 0
def _assign_alias(self, metric_name: str) -> int:
alias = self.next_alias
self.alias_map[metric_name] = alias
self.next_alias += 1
return alias
def connect_and_birth(self, node_metrics: list[tuple[str, DataType, object]]) -> None:
"""Register the MQTT Will as NDEATH, then connect, then publish NBIRTH."""
will_payload = SparkplugPayload(
timestamp=int(time.time() * 1000),
metrics=[Metric(name="bdSeq", datatype=DataType.INT64, value=self.bd_seq)],
)
will_topic = f"spBv1.0/{self.group_id}/NDEATH/{self.edge_node_id}"
self.client.will_set(will_topic, will_payload.SerializeToString(), qos=1, retain=False)
self.client.connect("broker.plant_eu", 1883, keepalive=30)
birth_metrics = [
Metric(name="bdSeq", datatype=DataType.INT64, value=self.bd_seq)
]
for name, dtype, value in node_metrics:
alias = self._assign_alias(name)
birth_metrics.append(Metric(name=name, alias=alias, datatype=dtype, value=value))
birth = SparkplugPayload(timestamp=int(time.time() * 1000), metrics=birth_metrics)
birth_topic = f"spBv1.0/{self.group_id}/NBIRTH/{self.edge_node_id}"
self.client.publish(birth_topic, birth.SerializeToString(), qos=0, retain=False)
self.bd_seq += 1
bdSeq (birth/death sequence number) is itself a required metric in every birth and death payload: it lets a host distinguish a genuine reconnect-and-rebirth from a stale, duplicated, or out-of-order birth message arriving after a network partition heals.
Alias and metric-map design Permalink to this section
The alias is Sparkplug’s answer to the field-name-repetition cost discussed in JSON vs Protobuf vs Avro for MQTT sensor payloads: a metric’s full name — Spindle/VibrationRMS — is transmitted once, at birth, mapped to a small integer alias; every subsequent DATA message references the metric by alias only. This is functionally the same trade-off Protobuf makes with field numbers, applied at the application layer instead of the wire-format layer.
def encode_data_update(session: EdgeNodeSession, metric_name: str, value: float) -> bytes:
"""DDATA/NDATA payloads reference metrics by alias, never by name,
after the corresponding birth has established the mapping."""
alias = session.alias_map.get(metric_name)
if alias is None:
raise ValueError(
f"{metric_name!r} was never registered in a birth payload; "
"a host receiving this alias with no matching birth cannot resolve it"
)
payload = SparkplugPayload(
timestamp=int(time.time() * 1000),
metrics=[Metric(alias=alias, datatype=DataType.FLOAT, value=value)],
)
return payload.SerializeToString()
The design contract this implies: the alias map is scoped to one edge_node_id’s lifetime between birth and death, never persisted across a rebirth as an assumption a consumer can rely on. A host that caches alias-to-name mappings must invalidate and re-resolve them from the new NBIRTH/DBIRTH on every reconnect — treating an alias as globally stable across restarts is a common integration bug that causes a host to silently misattribute metric values to the wrong physical signal after a gateway restarts and reassigns aliases in a different order.
Quick reference: message types Permalink to this section
| Message type | Scope | Published when | Retained | Contains |
|---|---|---|---|---|
NBIRTH |
Edge node | On connect, before any NDATA |
No | Full metric list + aliases for the node |
NDATA |
Edge node | On node-scoped metric change | No | Alias + value only |
NDEATH |
Edge node | Broker-delivered MQTT Will on ungraceful disconnect | No | bdSeq only |
DBIRTH |
Device | After NBIRTH, before device’s first DDATA |
No | Full metric list + aliases for the device |
DDATA |
Device | On device-scoped metric change | No | Alias + value only |
DDEATH |
Device | Graceful device removal by the edge node | No | bdSeq reference |
NCMD / DCMD |
Node / device | Host-to-edge command (setpoint, rebirth request) | No | Alias + command value |
STATE |
Host application | Host’s own online/offline status | Yes | JSON payload, host-specific |
Gotchas & anti-patterns Permalink to this section
- Treating
message_typeas a free-text segment. Publishing tospBv1.0/plant_eu/telemetry/gateway_04instead of a defined type likeNDATAis not Sparkplug B — any host library built against the spec will not recognize the topic and the birth/death state machine never engages. - Skipping
DBIRTHbefore the firstDDATA. A host that receivesDDATAfor a device it has no birth record for cannot resolve the aliases in the payload and must discard it or request a rebirth viaNCMD, either way losing data at the exact moment a device came online. - Persisting alias maps across a node restart. Aliases are only guaranteed stable within one connection’s birth-to-death lifetime; caching them past a reconnect risks resolving a new device’s data against a stale name.
- Retaining
NDATA/DDATAmessages. Unlike a plain MQTT design whereretain=trueis sometimes used for “current value” convenience, Sparkplug data messages are explicitly non-retained — current state is reconstructed from the birth certificate, and retaining data messages fights the specification’s own state model. - Skipping QoS discipline because “Sparkplug handles reliability.” Sparkplug’s birth/death lifecycle solves state reliability, not delivery reliability —
NBIRTH/DBIRTH/NDEATHshould still run at QoS 1 so a dropped birth does not leave a host with no metric map at all.
Related Permalink to this section
- MQTT Topic Hierarchies — parent topic on general-purpose ISA-95-aligned namespace design
- Best Practices for MQTT QoS Levels in Factory Networks — per-message-type delivery guarantees for birth, death, and data
- Payload Serialization Formats — the underlying protobuf encoding Sparkplug B’s payload is built on
- Schema Evolution for Versioned Sensor Payloads — additive-change discipline that also applies to birth-certificate metric lists