Skip to content

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.

Sparkplug B namespace tree: group, edge node, message type and device levels A left-to-right tree from the spBv1.0 namespace root through group_id, message type, edge_node_id and device_id, showing that the Sparkplug B message type segment sits between the group and the node/device identifiers rather than at the end of the topic. NAMESPACE GROUP MESSAGE TYPE EDGE NODE DEVICE spBv1.0 plant_eu NBIRTH NDATA NDEATH gateway_04 DBIRTH spindle_07 DDATA accent path = spBv1.0/plant_eu/NDATA/gateway_04/spindle_07 message type sits between group and edge_node_id — not at the end, unlike a plain ISA-95 tag path

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_type as a free-text segment. Publishing to spBv1.0/plant_eu/telemetry/gateway_04 instead of a defined type like NDATA is not Sparkplug B — any host library built against the spec will not recognize the topic and the birth/death state machine never engages.
  • Skipping DBIRTH before the first DDATA. A host that receives DDATA for a device it has no birth record for cannot resolve the aliases in the payload and must discard it or request a rebirth via NCMD, 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/DDATA messages. Unlike a plain MQTT design where retain=true is 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/NDEATH should still run at QoS 1 so a dropped birth does not leave a host with no metric map at all.