Skip to main content

Sparkplug B (Phase 1 + 3 enabled)

Manual for the Sparkplug B publisher (outbound) + command receiver (inbound) of PlantPulse Edge. It publishes NBIRTH / DBIRTH / DDATA / NDEATH on standard SPB 1.0 topics, and processes NCMD / DCMD sent by the host (SCADA), supporting tag writes and rebirth. Compatible with SCADA / HiveMQ / Ignition and others.

ItemValue
StatusPhase 1 (BIRTH/DATA/DEATH) + Phase 3 (NCMD/DCMD inbound) enabled. Phase 2 (alias) is optional via toggle
Controller packageplantpulse.app.edge.component.queue.transfer.sparkplug
Defaultsparkplug.enable=true (enabled in production)
Command receptionSubscribes to and processes NCMD/DCMD when sparkplug.cmd.enable=true
BrokerHiveMQ on the Edge (reuses mqtt.server.*)
AuthenticationMQTT username/password (mqtt.server.user / mqtt.server.password of app.properties). From 2026.05+, install.sh automatically generates a random password per box. The auth.properties of the HiveMQ broker is automatically synced with mqtt.* of app.properties by the container entrypoint on every boot — operators only change app.properties
Secrets in 2026.05+ container mode

The MQTT account is injected via MQTT_USER / MQTT_PASSWORD_FILE or MQTT_PASSWORD. When an external SCADA (Ignition, HiveMQ Edge, etc.) connects to this box over Sparkplug, the operator looks up the password in the operator-managed secret file before use.


Topic schema

spBv1.0/{group_id}/(N|D){BIRTH|DEATH|DATA|CMD}/{edge_node_id}[/{device_id}]
ItemMapping
group_idEdgeContext.site_id (e.g. SITE_00001). Can be overridden with sparkplug.group.id
edge_node_idEdgeContext.id (e.g. EDGE_00303). Can be overridden with sparkplug.edge.node.id
device_idopc_id (e.g. OPC_UA_Kepware) — sparkplug.device.strategy=per-opc
metric nametag_id

Message types

MessageTopicPayload essentialsPublish timing
NBIRTHspBv1.0/{g}/NBIRTH/{n}bdSeq metric, seq=0Immediately after connection
DBIRTHspBv1.0/{g}/DBIRTH/{n}/{d}All tag metrics of the given OPC (initial null), seq incrementedPer OPC immediately after connection
DDATAspBv1.0/{g}/DDATA/{n}/{d}Single metric (tag_id, value, datatype, ts), seq incrementedWhen one Point arrives from the queue
NDEATHspBv1.0/{g}/NDEATH/{n}bdSeq metric, no seq (per spec)MQTT will (abnormal termination) / explicitly published on disconnect
NCMDspBv1.0/{g}/NCMD/{n}host → edge node command (e.g. Node Control/Rebirth=true)Host user action / timeout / state sync
DCMDspBv1.0/{g}/DCMD/{n}/{d}host → edge device metric write (target metric name or alias + new value)When the host changes a specific OPC tag value as a setpoint

Lifecycle / sequence

  1. bdSeq — 0 to 255 wrap, persisted to disk (./data/sparkplug/bdseq by default). +1 at each session start.
  2. seq — 0 to 255 wrap. Starts at 0 in NBIRTH, incremented by 1 at every DBIRTH/DDATA publish.
  3. NDEATH = MQTT Will — Registered as the will at CONNECT, so the broker publishes it automatically on abnormal termination. On normal termination disconnect() publishes it explicitly.

Data type mapping

APP_TAG.data_type (free-form string) → MetricDataType (case-insensitive, trimmed).

edgeSPB
Float / SingleFloat
DoubleDouble
Byte / Int8Int8
Short / Int16Int16
Int / Integer / Int32Int32
Long / Int64Int64
UInt8/16/32/64UInt8/16/32/64
Boolean / BoolBoolean
String / TextString
DateTime / DateDateTime
Others / nullString (fallback, log.warn)

Point.value is stored as a String, so it must be cast to the appropriate type above before being placed into the SPB metric value. If the cast fails, the metric value is published as null.


Configuration (src/app.properties)

# --------------------------------------------------------------------------------------
# SPARKPLUG B
# 브로커 접속은 mqtt.server.* 를 그대로 재사용한다.
# --------------------------------------------------------------------------------------
sparkplug.enable=false
sparkplug.group.id= # 비우면 site_id
sparkplug.edge.node.id= # 비우면 EdgeContext.id
sparkplug.device.strategy=per-opc # per-opc | single (현재 per-opc 만 구현)
sparkplug.qos=0 # DDATA 빈번 → 0 권장
sparkplug.bdseq.path= # 비우면 ./data/sparkplug/bdseq
sparkplug.alias.enable=true # Phase 2 토글 (현 무영향)
sparkplug.cmd.enable=false # Phase 3 토글 (현 무영향)

Components

FileRole
SparkPlugQueueTransfer.javaQueueTransfer implementation. Exposes only init/trans/close
SparkPlugSession.javaLifecycle (CONNECT+will / NBIRTH / DBIRTH / DDATA / disconnect-NDEATH / seq / bdSeq)
SparkPlugConfig.javaProperties → configuration object (broker reuses mqtt.server.*)
SparkPlugTopicBuilder.javaTopic string builder
SparkPlugDataTypeMapper.javadata_type → MetricDataType + value coercion
SparkPlugBdSeqStore.javabdSeq disk persistence (0 to 255 wrap)
PointQueueProcessorFactory.javaAdds SparkPlugQueueTransfer to transfer_list

Dependency jars

Added to WebContent/WEB-INF/lib/:

jarRole
tahu-core-1.0.14.jarEclipse Tahu Sparkplug B payload model + encoder
protobuf-java-3.25.5.jartahu's protobuf dependency
commons-compress-1.27.1.jartahu's commons-compress dependency
lib synchronization

The jars in WEB-INF/lib are materialized from Nexus at build time and are therefore not committed. New dependencies require adding their coordinates to build.gradle.


Verification (dev server / 2026-05-06)

Capturing spBv1.0/# with the paho-mqtt subscriber:

[SUMMARY] total=511
NDEATH: 1 ← 재시작 시 will 자동 발행
NBIRTH: 1 ← seq=0
DBIRTH: 10 ← OPC 10개 모두, seq=1..10
DDATA: 499 ← 13초간 ≈ 38 msgs/s

The DBIRTH metric count matches exactly the number of APP_TAG tags for that OPC (OPC_UA_Kepware=11, OPC_00303=56, OPC_LS_XBM_0001=28, etc.).

DDATA topic example:

spBv1.0/SITE_00001/DDATA/EDGE_00303/OPC_UA_Kepware bytes=44 seq=125 ts=...

Payload decoding verification tool:

MQTT_USER="${MQTT_USER:-edge}" \
MQTT_PASSWORD="$(tr -d '\r\n' < /run/secrets/mqtt-password)" \
python3 /tmp/spb_sub.py 100.106.92.21 1883 "$MQTT_USER" "$MQTT_PASSWORD" 60

(This script is a temporary file and is not included in build artifacts.)


Phase 3 — Command (NCMD/DCMD) reception behavior

When sparkplug.cmd.enable=true, SparkPlugSession automatically subscribes to the following topics.

spBv1.0/{group}/NCMD/{edge_node}
spBv1.0/{group}/DCMD/{edge_node}/+ # 모든 device 와일드카드

Incoming messages are validated by the static method parseCmdTopic(String) → CmdTopic and then dispatched.

CommandHandling
NCMD Node Control/Rebirth=truerebirthAsync() — republishes NBIRTH + republishes DBIRTH for all OPCs + (when alias is enabled) refreshes aliasReverseMap (refreshAliasReverseMap())
DCMD metric writeReverse-lookup of tag_id by metric name or alias → calls the driver write(tagId, value) of that OPC → the result is reflected in the next DDATA cycle

Topic validation (parseCmdTopic)

InputResult
spBv1.0/Plant1/NCMD/EDGE_00303OK (NCMD, deviceId=null)
spBv1.0/Plant1/DCMD/EDGE_00303/OPC_LSOK (DCMD, deviceId=OPC_LS)
null / empty string / fewer than 4 segmentsreject (null)
spBv1.0/Plant1/NDATA/EDGE_00303reject (outbound type)
Other namespace (spBv2.0/...)reject
spBv1.0/Plant1/DCMD/EDGE_00303 (4 seg, deviceId missing)reject
6+ segmentsExtra segments ignored (only 4-5 used)

→ The unit test SparkPlugCommandTopicTest covers the 10 cases above.

rebirth safety net

The defect where rebirthAsync() republished NBIRTH/DBIRTH but omitted the aliasReverseMap refresh, causing the next DCMD to look up a stale alias, has been fixed — a refreshAliasReverseMap() call was added (subscribeCommands() uses the same helper, DRY).


Roadmap

PhaseContentStatus
1 — BIRTH/DATA/DEATHNBIRTH / DBIRTH / DDATA / NDEATH(will)✅ Done
2 — alias optimizationBuilds the tag_id → alias(Long) table at NBIRTH/DBIRTH time, DDATA publishes only the alias (30-40% payload reduction). Toggle: sparkplug.alias.enableOptional (toggle)
3 — NCMD/DCMD inboundHandles metric write commands sent by the host + Node Control/Rebirth✅ Done (sparkplug.cmd.enable=true)
4 — STATE / operational visibilitySubscribe to STATE/{primary_host_id} → hold DDATA when primary is OFFLINE. Add a sparkplug section to the /api/v1/edge response (connected / bdSeq / seq / devices)TBD

Known limitations

  • isNull flag not set when DDATA value=null: currently only Metric.setValue(null) is called. Per spec, explicitly setting isNull=true is safer → candidate for a follow-up improvement.
  • Single metric per DDATA: since the queue transfers one item at a time, batched publishing is not optimized. If the frequency increases, this could evolve into batched DDATA.
  • bdSeq path defaults to a path relative to the working directory: on production servers an explicit absolute path (/opt/kopens/.../data/sparkplug/bdseq) is recommended.
  • DCMD write failure response not implemented: currently, even if the driver write() fails, the result is not reported back to the host via NDATA/DDATA. Adding a result_status metric is under review for the future.