flexway
FRENTalk to an expert

Sparkplug B and OPC UA PubSub JSON: the plant floor under contract

Sparkplug B nodes, host applications and OPC UA PubSub JSON publishers behind the gateway: roles, node table, failover, settings.

ConceptOperationsSince version 1.7.0

Markdown

On this page

Operations guide: configure, deploy and observe the gateway. What the feature does for the product: product guide; connecting a node or a publisher: developer guide.

On the MQTT device listener, two industrial standards come under the same contract: Sparkplug B 3.0 - plant edge nodes, their devices, host applications (SCADA, MES) and their STATE - and OPC UA PubSub in JSON over MQTT. The gateway judges each packet, relays it, then reads what crossed to keep the nodes' state; it never speaks Sparkplug itself (no rebirth requested, no STATE, no death emitted). Available since 1.7.0.

Conformance - tested end to end against Mosquitto, EMQX (one node) and HiveMQ CE. The Sparkplug conformance kit (TCK 3.0.0) and the Eclipse Tahu nodes have not been run yet: no certified conformance is promised.

Sparkplug B: the example

Edge nodes publish births, data and deaths; a host application of the same tenant commands them and announces its STATE. Each tenant lives in its group of the spBv1.0 namespace, each node under its identity.

# Sparkplug B 3.0 behind the gateway (1.7.0): edge nodes publish their births, data and deaths, a host
# application (SCADA, MES) commands them and announces its STATE - each tenant inside its own group of
# the namespace spBv1.0, every node's id bound to its identity. The gateway reads births,
# deaths and sequences into each replica's node table (GET /sparkplug/nodes, the console's Devices
# screen) and never speaks Sparkplug itself.
version: 1
contracts:
  asyncapi: ./asyncapi.yaml
upstreams:
  plant:
    protocol: mqtt
    brokers: ["mqtts://broker.plant.svc.cluster.local:8883"]
    sasl: { mechanism: PLAIN, username: flexway, password: "file:/etc/flexway-secrets/broker/password" }
    timeout: 5s
auth:
  api_keys:
    store: "file:./api-keys.yaml"                # a node's key id is the edge node id its topics carry
listeners:
  mqtt:
    address: ":1883"
    mesh_only: true                              # with TLS and device certificates, bind edge_node to cert:cn
    advertised_address: "mqtt.plant.example.com:1883"
observability:
  sparkplug:
    max_nodes: 10000                             # per replica; full of online nodes, a new one is counted, not kept
    offline_retention: 24h                       # an offline node stays listed this long
# examples/sparkplug/asyncapi.yaml
asyncapi: 3.0.0
info: { title: Plant Sparkplug B, version: "1.0.0" }
channels:
  edgeNode:
    # The edge node's own topics: its births, deaths and data, its devices', its commands. The + is the
    # message type, admitted by the operation's role; the group is the tenant, the node its identity.
    address: "spBv1.0/{group}/+/{edge_node}/#"
    parameters:
      group: { x-flexway-bind: tenant }
      edge_node: { x-flexway-bind: subject }       # the key's id; "cert:cn" on a TLS listener
  group:
    # The host application's view of its group.
    address: "spBv1.0/{group}/#"
    parameters:
      group: { x-flexway-bind: tenant }
  hostState:
    # STATE has no group: the host id is bound to the tenant, so two tenants never share one.
    address: "spBv1.0/STATE/{host_id}"
    parameters:
      host_id: { x-flexway-bind: tenant }
operations:
  nodePublishes:
    action: send                                 # NBIRTH, NDEATH, DBIRTH, DDEATH, NDATA, DDATA; the will: NDEATH only
    channel: { $ref: "#/channels/edgeNode" }
    x-flexway-upstream: plant
    x-flexway-validation: full                   # every payload under the embedded sparkplug_b.proto: 0x99 otherwise
    x-flexway-device: { qos_max: 1 }
    x-flexway-sparkplug: edge_node
    security: [ { type: userPassword } ]
  nodeCommands:
    action: receive                              # NCMD, DCMD addressed to the node
    channel: { $ref: "#/channels/edgeNode" }
    x-flexway-upstream: plant
    x-flexway-device: true
    x-flexway-sparkplug: edge_node
    security: [ { type: userPassword } ]
  nodeWatchesHost:
    action: receive                              # the primary host's STATE
    channel: { $ref: "#/channels/hostState" }
    x-flexway-upstream: plant
    x-flexway-device: true
    x-flexway-sparkplug: state
    security: [ { type: userPassword } ]
  hostCommands:
    action: send                                 # NCMD, DCMD only: a host never publishes a birth
    channel: { $ref: "#/channels/group" }
    x-flexway-upstream: plant
    x-flexway-device: { qos_max: 1 }
    x-flexway-sparkplug: host
    security: [ { type: userPassword } ]
  hostReads:
    action: receive
    channel: { $ref: "#/channels/group" }
    x-flexway-upstream: plant
    x-flexway-device: true
    x-flexway-sparkplug: host
    security: [ { type: userPassword } ]
  hostState:
    action: send                                 # {"online": true, "timestamp": ...}, its will the offline one
    channel: { $ref: "#/channels/hostState" }
    x-flexway-upstream: plant
    x-flexway-device: { qos_max: 1 }
    x-flexway-sparkplug: state
    security: [ { type: userPassword } ]

Roles of a Sparkplug operation

A device operation (x-flexway-device) takes a role with x-flexway-sparkplug. Its channel has exactly the role's shape, the group bound to the tenant - any other shape, and any channel in spBv1.0/ without a role, is refused at load: two tenants never meet in the namespace.

Role Channel send receive Will admitted
edge_node spBv1.0/{group}/+/{node}/# - the + is the message type, the node bound to the identity (subject, claim:<name>, cert:<field>) NBIRTH, NDEATH, DBIRTH, DDEATH, NDATA, DDATA Its NCMD, DCMD Its NDEATH only
host spBv1.0/{group}/# NCMD, DCMD only - never a birth The whole group None
state spBv1.0/STATE/{host_id} - host_id bound to the tenant Its STATE {"online": ..., "timestamp": ...} Its tenant's STATE Its STATE only
  • One host application per tenant through the gateway: host_id is the tenant's name (spBv1.0/STATE/acme). The STATE topic has no group; bound to anything else, two tenants would share one STATE.
  • Commands are mutations: with a token, every alternative of the security of a host operation in send names its scopes (refused at load otherwise); a key or a certificate is bound to the operation.
  • A tenant named STATE never gets a Sparkplug operation: its host channel would cover every tenant's STATE.
  • Payload: a Sparkplug operation without a message gets the B payload under the embedded sparkplug_b.proto, or STATE's JSON schema. With x-flexway-validation: full, every payload is checked against it (0x99 otherwise); without it, a malformed payload is relayed and changes nothing in the table.

On a TLS listener, bind the node to its certificate:

channels:
  edgeNode:
    address: "spBv1.0/{group}/+/{edge_node}/#"
    parameters:
      group: { x-flexway-bind: tenant }
      edge_node: { x-flexway-bind: "cert:cn" }   # the device certificate's common name
operations:
  nodePublishes:
    action: send
    channel: { $ref: "#/channels/edgeNode" }
    x-flexway-upstream: plant
    x-flexway-device: { qos_max: 1 }
    x-flexway-sparkplug: edge_node
    security: [ { type: X509 } ]

The node table

Each replica keeps the table of the edge nodes and host applications that cross it, read after the relay: seq and the bdSeq metric only, never delaying nor refusing an admitted packet.

State When
online Its NBIRTH crossed this replica, its connection is open here
offline Its NDEATH, or the end of its connection with its will
moved This replica handed its connection over to another (failover): a trace, never offline
unknown Only commands to it were seen here
  • Gaps: a seq that is not the next one, or a birth's bdSeq that differs from its will's, are counted - never blocking.
  • Commands: NCMD/DCMD admitted and refused counted per node (a refusal only on a node of its own tenant). Commands published through the HTTP door x-flexway-publish are not tracked.
  • Host applications: their STATE is read from their publication, their will and the retained STATE delivered to the tenant's devices.
  • Bounds: when full, the table forgets an unknown node first, then the oldest offline or moved one; full of online nodes, a new one is not kept and is counted (dropped). It is lost at a restart.
curl -s 'localhost:9090/sparkplug/nodes?tenant=acme'
# {"replica":"flexway-7c9d-x2","capacity":10000,"offline_retention_seconds":86400,"dropped":0,
#  "nodes":[{"tenant":"acme","edge_node":"press-line-1","state":"online","since":"2026-10-23T08:12:04Z",
#    "bd_seq":4,"will_bd_seq":4,"seq_gaps":0,"bd_seq_gaps":0,"protocol_version":"5","auth":"api_key",
#    "devices":[{"device":"press-07","state":"online"}],"devices_truncated":false,
#    "commands":{"admitted":3,"refused":0}}],
#  "hosts":[{"tenant":"acme","host_id":"acme","state":"online","since":"2026-10-23T08:11:58Z"}]}

GET /sparkplug/nodes lives on the admin port (404 without an MQTT listener); bd_seq and will_bd_seq are absent when nothing was read. The console reads every replica's table and merges them (Console: the Devices screen).

Host applications connected directly

NCMD/DCMD commands are governed - scopes, audit, counts - only when the host application goes through the gateway. A host application connected directly to the broker escapes all of it: bound it with the broker's ACLs (publish only on spBv1.0/<its group>/NCMD/# and DCMD/#, STATE on its own topic only).

Lost replica, shutdown and failover

Case What the broker and the console see
Draining shutdown The node comes back through another replica with the same client id: the broker resumes its session, no NDEATH; the old replica keeps a moved trace, the console shows the node online and rehomed
Lost replica Its connections drop: the broker publishes a true NDEATH - it follows a gateway failure, not the node's. No moved trace: the node is reborn (NBIRTH, bdSeq + 1) online on the replica that receives it
Alert sparkplug-node-offline waits 2 min (for) on the merged table: a node back in the meantime fires nothing

Will on takeover, handover_timeout and will_delay_on_handover: Failover.

Brokers

Broker Tested Good to know
Mosquitto 2 Yes -
EMQX 6 Yes, one node BSL 1.1 licence since 5.9: one node free, a cluster under a commercial licence
HiveMQ CE Yes Carries the Sparkplug conformance kit (a HiveMQ extension)

Sparkplug's "aware" server ($sparkplug/certificates/...) stays with the broker: the gateway does not do it.

OPC UA PubSub JSON

No extension and no OPC UA stack: a publisher sending its JSON NetworkMessages (Part 14, ua-data) over MQTT is a device under an ordinary contract. Part 14's topic, <prefix>/json/data/<PublisherId>/<WriterGroup>: the tenant in the prefix, the PublisherId bound to the identity; the message validated by its JSON Schema (0x99 otherwise). The UADP binary encoding is not served.

# OPC UA PubSub over MQTT with the JSON message mapping (OPC UA Part 14, 1.7.0): publishers of a plant
# send their NetworkMessages through the gateway, each under its tenant and its own publisher id, each
# message checked against the contract's schema; subscribers read their tenant's data. No OPC UA stack
# in the gateway: a NetworkMessage is a JSON payload under a JSON Schema, like any other.
version: 1
contracts:
  asyncapi: ./asyncapi.yaml
upstreams:
  plant:
    protocol: mqtt
    brokers: ["mqtts://broker.plant.svc.cluster.local:8883"]
    timeout: 5s
auth:
  api_keys:
    store: "file:./api-keys.yaml"                # the key's id is the publisher id the topic carries
listeners:
  mqtt:
    address: ":1883"
    mesh_only: true
    advertised_address: "mqtt.plant.example.com:1883"
# examples/opcua-pubsub/asyncapi.yaml
asyncapi: 3.0.0
info: { title: OPC UA PubSub JSON, version: "1.0.0" }
channels:
  data:
    # Part 14's topic: <prefix>/json/data/<PublisherId>/<WriterGroup>, the prefix carrying the tenant.
    address: "opcua/{site}/json/data/{publisher}/line"
    parameters:
      site: { x-flexway-bind: tenant }
      publisher: { x-flexway-bind: subject }
    messages:
      networkMessage: { $ref: "#/components/messages/networkMessage" }
  allData:
    address: "opcua/{site}/json/data/#"
    parameters:
      site: { x-flexway-bind: tenant }
    messages:
      networkMessage: { $ref: "#/components/messages/networkMessage" }
operations:
  publishData:
    action: send
    channel: { $ref: "#/channels/data" }
    x-flexway-upstream: plant
    x-flexway-validation: full
    x-flexway-device: { qos_max: 1 }
    security: [ { type: userPassword } ]
  readData:
    action: receive
    channel: { $ref: "#/channels/allData" }
    x-flexway-upstream: plant
    x-flexway-device: { qos_max: 1 }
    security: [ { type: userPassword } ]
components:
  messages:
    networkMessage:
      contentType: application/json
      payload:
        # A NetworkMessage of the JSON mapping (OPC UA Part 14, section 7.2.5.3): ua-data, its
        # DataSetMessages and their fields.
        type: object
        required: [MessageId, MessageType, Messages]
        properties:
          MessageId: { type: string, maxLength: 64 }
          MessageType: { const: ua-data }
          PublisherId: { type: string, maxLength: 128 }
          WriterGroupName: { type: string }
          Messages:
            type: array
            minItems: 1
            maxItems: 64
            items:
              type: object
              required: [Payload]
              properties:
                DataSetWriterId: { type: integer, minimum: 0, maximum: 65535 }
                SequenceNumber: { type: integer, minimum: 0 }
                Timestamp: { type: string, format: date-time }
                MessageType: { enum: [ua-keyframe, ua-deltaframe, ua-event, ua-keepalive] }
                Payload: { type: object, maxProperties: 256 }

Settings and defaults

The listener's (listeners.mqtt.*, failover.*, observability.mqtt.closures, chart mqtt.enabled): Settings and defaults. The console's (five built-in rules, devices.readerBounds): Console: the Devices screen. Sparkplug's own, reloaded live:

Key (observability.sparkplug.) Default Why
max_nodes 10,000 per replica (1 to 50,000: a node is a connection) Edge nodes and host applications kept; the table's memory bound
max_nodes_per_tenant 1,000, or max_nodes when lower (1 to max_nodes) One tenant's nodes: a tenant never fills the others' table; at its bound its own unknown, then oldest offline nodes are forgotten, otherwise the new one is counted (dropped)
offline_retention 24h (1 min to 720 h) How long an offline, moved or unknown node stays listed
max_devices_per_node 1,000 (1 to 10,000) Devices (DBIRTH, DDEATH) kept per node; past it, the list is said truncated

A group, node, device or host id longer than 255 bytes is no Sparkplug topic for the gateway: the publication is refused (0x87) and nothing is kept. A refused command never creates an entry in the table. A tenant name starting with _ is the gateway's own (_anonymous, _other in the metrics): a key, a certificate client or a claim carrying one is refused.

observability:
  sparkplug:
    max_nodes: 20000
    max_nodes_per_tenant: 2000
    offline_retention: 72h          # a weekend without a node still shows it
    max_devices_per_node: 1000

Observing

Signal What it says
flexway.sparkplug.nodes{tenant, state} Nodes of the table, online and offline; a moved node is neither
flexway.sparkplug.sequence.gaps{tenant, kind} seq and bdseq gaps
flexway.mqtt.packets{type, decision, cause} Packets refused by the role (not_authorized) or the schema (payload_invalid)
GET /sparkplug/nodes The names - never in a metric

Troubleshooting

What the node or the operator sees Why Where to look
Refused at load: a x-flexway-sparkplug edge_node operation's channel is spBv1.0/{group}/+/{edge_node}/# The channel does not have the role's shape, or the group is not bound to the tenant The table of roles
Refused at load: writes ... in the Sparkplug namespace spBv1.0/ A channel in spBv1.0/ without x-flexway-sparkplug Mark the operation with its role
Refused at load: admits a token without scopes A host operation in send admits a token without scopes Name the commands' scopes
A node's CONNACK 0x87 Its will is not its NDEATH (or, for state, its STATE) The will configured in the node
PUBACK 0x87 A type the role does not publish (a birth by the host, a command by the node), or another's group or node POST /simulate/mqtt with the topic
PUBACK 0x99 Payload outside sparkplug_b.proto or the OPC UA schema (x-flexway-validation: full) The payload sent
Node missing from the table Commands sent through HTTP (not tracked), table full (dropped), replica restarted GET /sparkplug/nodes of every replica
Node offline after a replica failure The broker's NDEATH is true; the node is reborn elsewhere as soon as it reconnects The console: online on another replica

Next steps