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.
| Item | Value |
|---|---|
| Status | Phase 1 (BIRTH/DATA/DEATH) + Phase 3 (NCMD/DCMD inbound) enabled. Phase 2 (alias) is optional via toggle |
| Controller package | plantpulse.app.edge.component.queue.transfer.sparkplug |
| Default | sparkplug.enable=true (enabled in production) |
| Command reception | Subscribes to and processes NCMD/DCMD when sparkplug.cmd.enable=true |
| Broker | HiveMQ on the Edge (reuses mqtt.server.*) |
| Authentication | MQTT 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 |
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}]
| Item | Mapping |
|---|---|
group_id | EdgeContext.site_id (e.g. SITE_00001). Can be overridden with sparkplug.group.id |
edge_node_id | EdgeContext.id (e.g. EDGE_00303). Can be overridden with sparkplug.edge.node.id |
device_id | opc_id (e.g. OPC_UA_Kepware) — sparkplug.device.strategy=per-opc |
metric name | tag_id |
Message types
| Message | Topic | Payload essentials | Publish timing |
|---|---|---|---|
| NBIRTH | spBv1.0/{g}/NBIRTH/{n} | bdSeq metric, seq=0 | Immediately after connection |
| DBIRTH | spBv1.0/{g}/DBIRTH/{n}/{d} | All tag metrics of the given OPC (initial null), seq incremented | Per OPC immediately after connection |
| DDATA | spBv1.0/{g}/DDATA/{n}/{d} | Single metric (tag_id, value, datatype, ts), seq incremented | When one Point arrives from the queue |
| NDEATH | spBv1.0/{g}/NDEATH/{n} | bdSeq metric, no seq (per spec) | MQTT will (abnormal termination) / explicitly published on disconnect |
| NCMD | spBv1.0/{g}/NCMD/{n} | host → edge node command (e.g. Node Control/Rebirth=true) | Host user action / timeout / state sync |
| DCMD | spBv1.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
bdSeq— 0 to 255 wrap, persisted to disk (./data/sparkplug/bdseqby default).+1at each session start.seq— 0 to 255 wrap. Starts at 0 in NBIRTH, incremented by 1 at every DBIRTH/DDATA publish.- 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).
| edge | SPB |
|---|---|
| Float / Single | Float |
| Double | Double |
| Byte / Int8 | Int8 |
| Short / Int16 | Int16 |
| Int / Integer / Int32 | Int32 |
| Long / Int64 | Int64 |
| UInt8/16/32/64 | UInt8/16/32/64 |
| Boolean / Bool | Boolean |
| String / Text | String |
| DateTime / Date | DateTime |
| Others / null | String (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
| File | Role |
|---|---|
SparkPlugQueueTransfer.java | QueueTransfer implementation. Exposes only init/trans/close |
SparkPlugSession.java | Lifecycle (CONNECT+will / NBIRTH / DBIRTH / DDATA / disconnect-NDEATH / seq / bdSeq) |
SparkPlugConfig.java | Properties → configuration object (broker reuses mqtt.server.*) |
SparkPlugTopicBuilder.java | Topic string builder |
SparkPlugDataTypeMapper.java | data_type → MetricDataType + value coercion |
SparkPlugBdSeqStore.java | bdSeq disk persistence (0 to 255 wrap) |
PointQueueProcessorFactory.java | Adds SparkPlugQueueTransfer to transfer_list |
Dependency jars
Added to WebContent/WEB-INF/lib/:
| jar | Role |
|---|---|
tahu-core-1.0.14.jar | Eclipse Tahu Sparkplug B payload model + encoder |
protobuf-java-3.25.5.jar | tahu's protobuf dependency |
commons-compress-1.27.1.jar | tahu's commons-compress dependency |
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.
| Command | Handling |
|---|---|
NCMD Node Control/Rebirth=true | rebirthAsync() — republishes NBIRTH + republishes DBIRTH for all OPCs + (when alias is enabled) refreshes aliasReverseMap (refreshAliasReverseMap()) |
DCMD metric write | Reverse-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)
| Input | Result |
|---|---|
spBv1.0/Plant1/NCMD/EDGE_00303 | OK (NCMD, deviceId=null) |
spBv1.0/Plant1/DCMD/EDGE_00303/OPC_LS | OK (DCMD, deviceId=OPC_LS) |
null / empty string / fewer than 4 segments | reject (null) |
spBv1.0/Plant1/NDATA/EDGE_00303 | reject (outbound type) |
Other namespace (spBv2.0/...) | reject |
spBv1.0/Plant1/DCMD/EDGE_00303 (4 seg, deviceId missing) | reject |
| 6+ segments | Extra 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
| Phase | Content | Status |
|---|---|---|
| 1 — BIRTH/DATA/DEATH | NBIRTH / DBIRTH / DDATA / NDEATH(will) | ✅ Done |
| 2 — alias optimization | Builds the tag_id → alias(Long) table at NBIRTH/DBIRTH time, DDATA publishes only the alias (30-40% payload reduction). Toggle: sparkplug.alias.enable | Optional (toggle) |
| 3 — NCMD/DCMD inbound | Handles metric write commands sent by the host + Node Control/Rebirth | ✅ Done (sparkplug.cmd.enable=true) |
| 4 — STATE / operational visibility | Subscribe 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 settingisNull=trueis 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 aresult_statusmetric is under review for the future.