diff --git a/docs/drivers/Mqtt.md b/docs/drivers/Mqtt.md
index 6d1b921e..c01aeb83 100644
--- a/docs/drivers/Mqtt.md
+++ b/docs/drivers/Mqtt.md
@@ -2,7 +2,8 @@
In-process MQTT **broker-subscribe** driver. It holds one MQTTnet 5 client against one broker,
subscribes to the topics its authored tags name, and forwards each PUBLISH straight to
-`ISubscribable.OnDataChange`. Ingest is **push, not poll**, and **one-way** — there is no `IWritable`.
+`ISubscribable.OnDataChange`. Ingest is **push, not poll**; writes go back out as ordinary publishes
+(see [Write-through](#write-through)).
**Two ingest shapes, selected by the driver's `Mode`:** `Plain` binds a tag to a concrete MQTT topic;
`SparkplugB` binds it to a `group / edge node / device? / metric` tuple and decodes Eclipse-Tahu
@@ -12,18 +13,18 @@ See [Mqtt-Test-Fixture.md](Mqtt-Test-Fixture.md) for the harness and
[`../plans/2026-07-15-mqtt-sparkplug-driver-design.md`](../plans/2026-07-15-mqtt-sparkplug-driver-design.md)
for the design.
-> **Status.** P1 (plain MQTT) and P2 (Sparkplug B ingest) are both shipped and live-gated against the
-> Mosquitto + Sparkplug-simulator fixture. Write-through (`IWritable` — NCMD/DCMD/plain publish) is
-> **deferred** ([#508](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/508)); every MQTT node
-> materializes read-only. See [Known gaps](#known-gaps) — every entry there carries a tracking issue
-> (#507–#515).
+> **Status.** P1 (plain MQTT), P2 (Sparkplug B ingest), write-through
+> ([#508](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/508)) and the Sparkplug primary-host
+> claim ([#511](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/511)) are all shipped and
+> live-gated against the Mosquitto + Sparkplug-simulator fixture. See
+> [Known gaps](#known-gaps) — every entry there carries a tracking issue.
## Project Layout
| Project | Holds |
|---|---|
| `Driver.Mqtt.Contracts` | Config records, enums, `MqttTagDefinition` + the pure `MqttTagDefinitionFactory`, and the one shared `MqttJson.Options`. **Deliberately transport-free** — no MQTTnet reference |
-| `Driver.Mqtt` | Runtime: `MqttDriver`, `MqttConnection` (connect + reconnect supervisor), `MqttSubscriptionManager`, `LastValueCache`, factory registration, `MqttDriverProbe` |
+| `Driver.Mqtt` | Runtime: `MqttDriver`, `MqttConnection` (connect + reconnect supervisor), `MqttSubscriptionManager`, `LastValueCache`, `SparkplugIngestor`, `SparkplugPrimaryHost`, factory registration, `MqttDriverProbe` |
| `Driver.Mqtt.Transport` | `MqttClientOptionsFactory` — TLS posture, PEM CA-pin chain validator, credentials, protocol mapping. Connection **policy** only; nothing here connects |
| `Driver.Mqtt.Browser` | `MqttDriverBrowser` + `MqttBrowseSession` — the AdminUI address picker's `#`-observation session |
@@ -113,8 +114,8 @@ driver are pure organisational grouping in the RawPath.
"Mode": "SparkplugB",
"Sparkplug": {
"GroupId": "OtOpcUaSim", // REQUIRED — the driver's whole subscription scope
- "HostId": "", // Host Application identity; receive-only (see below)
- "ActAsPrimaryHost": false, // NOT IMPLEMENTED — logs a warning when true
+ "HostId": "", // Host Application identity — spBv1.0/STATE/{HostId}
+ "ActAsPrimaryHost": false, // claim the primary host id (Primary-gated; see below)
"RequestRebirthOnGap": true, // a seq gap asks the edge node to re-announce (NCMD)
"BirthObservationWindowSeconds": 15 // how long discovery collects births before calling it stable
}
@@ -124,8 +125,8 @@ driver are pure organisational grouping in the RawPath.
| Key | Default | Notes |
|---|---|---|
| `GroupId` | `""` | **Required.** The driver subscribes exactly `spBv1.0/{GroupId}/#`. Blank ⇒ it connects, reports `Healthy`, and ingests **nothing**. It is a literal topic segment, so `/`, `+`, `#` are refused by the form |
-| `HostId` | `""` | Sparkplug Host Application identity. **Receive-only** — the driver observes `STATE` and never publishes one |
-| `ActAsPrimaryHost` | `false` | **Not implemented.** Setting it logs a startup **warning** rather than being silently inert — being a primary host is a three-part contract (retained `ONLINE`, a matching `OFFLINE` registered as the MQTT Will *at connect time*, an explicit `OFFLINE` on clean shutdown), and shipping the `ONLINE` half without the Will is strictly worse than shipping neither: a crashed node would leave a retained `ONLINE` asserting forever that a dead host is alive |
+| `HostId` | `""` | Sparkplug Host Application identity — the `spBv1.0/STATE/{HostId}` topic. Optional for observation; **required** to claim. Set without `ActAsPrimaryHost`, the driver only observes |
+| `ActAsPrimaryHost` | `false` | Claim the Sparkplug Primary Host Application identity. **Primary-gated** — only the node holding the redundancy Primary role claims it. See [Primary host](#primary-host-actasprimaryhost) |
| `RequestRebirthOnGap` | `true` | On a detected sequence-number gap, publish a rebirth-request NCMD instead of running on stale metric state |
| `BirthObservationWindowSeconds` | `15` | Discovery's birth-collection window. Must be ≥ 1 |
@@ -203,7 +204,7 @@ survive a reconnect; a message fans out to *every* matching tag, and unauthored
| `groupId` | string | — | **Required.** Must match the driver's `Sparkplug.GroupId` to ever receive a message |
| `edgeNodeId` | string | — | **Required.** |
| `deviceId` | string | *absent* | **Omit** for a metric published by the edge node itself (NBIRTH/NDATA). Absent is meaningfully different from `""` |
-| `metricName` | string | — | **Required.** The tag's stable binding key across rebirths. **May contain `/`** — `Node Control/Rebirth` and `Properties/Serial Number` are canonical Sparkplug names, and the whole name is one value, never split |
+| `metricName` | string | — | **Required.** The tag's stable binding key across rebirths. **May contain `/`** — `Node Control/Rebirth` and `Properties/Serial Number` are canonical Sparkplug names, and the whole name is one value, never split. Also how a flattened structured member is addressed: `Motor/Speed`, `Recipe/Target/0` (see [Structured metrics](#structured-metrics-template--dataset--propertyset)) |
| `dataType` | `DriverDataType` | *from the birth* | **Optional override.** Absent ⇒ take whatever type the birth certificate declared. The AdminUI's "(from birth certificate)" option removes the key entirely |
| `isHistorized` / `historianTagname` | — | — | Server-side historization, unchanged from Plain |
@@ -221,6 +222,11 @@ bind. `metricName` is deliberately **exempt**: it is not a topic segment.
// A node-level metric — note deviceId is ABSENT, not blank.
{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA","metricName":"Temperature"}
+
+// A flattened Template member, and a flattened DataSet cell. `Motor` and `Recipe` are what the wire
+// carries; nothing on the wire is ever called by these names.
+{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA","deviceId":"Filler1","metricName":"Motor/Stator/Temp"}
+{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA","deviceId":"Filler1","metricName":"Recipe/Target/1"}
```
## Sparkplug B Ingest
@@ -239,9 +245,11 @@ One subscription (`spBv1.0/{GroupId}/#`) feeds a per-edge-node state machine
- **Death → STALE.** An NDEATH whose `bdSeq` matches the live session (the bdSeq tie is what stops a
stale death from killing a fresh session) marks every metric under that edge node `Bad` /
`BadNoCommunication`. A DDEATH does the same for one device. The next birth restores them.
-- **Unsupported datatypes are skipped with a warning, never guessed**: `DataSet`, `Template`,
- `PropertySet`, `PropertySetList`, `Unknown`. `Int8`→`Int16` and `UInt8`→`UInt16` widen; `*Array`
- variants map to the element type with the array bit set.
+- **Structured metrics flatten to sibling tags** — `Template` and `DataSet`; see
+ [Structured metrics](#structured-metrics-template--dataset--propertyset).
+- **Unsupported datatypes are skipped with a warning, never guessed**: `PropertySetList`, `Unknown`,
+ extension values, and any `Template`/`DataSet` that cannot be flattened honestly. `Int8`→`Int16` and
+ `UInt8`→`UInt16` widen; `*Array` variants map to the element type with the array bit set.
- **Ingest state is reset at every session/authoring seam** — a reconnect or a `ReinitializeAsync`
must not carry a previous session's alias table into a new one.
@@ -252,12 +260,127 @@ One subscription (`spBv1.0/{GroupId}/#`) feeds a per-edge-node state machine
anti-storm, not optimisations: rediscovery triggers an address-space rebuild, and an edge node
re-births freely — on its own schedule, on every reconnect, and once per rebirth NCMD.
-> ⚠️ **Nothing consumes the signal today.** No runtime component subscribes to
-> `IRediscoverable.OnRediscoveryNeeded`, and `DriverHostActor.HandleDiscoveredNodes` hard-returns —
-> discovered-node injection is dormant in v3. The served address space comes from the deploy artifact,
-> so **a DBIRTH introducing a new metric does not change the OPC UA tree**; you must redeploy. The
-> driver's decision is observable only as an Information log line
-> (`… requesting rediscovery.`). This is a v3 platform gap, not an MQTT one.
+> ⚠️ **The signal is an operator prompt, not a rebuild.** `DriverInstanceActor` subscribes and the
+> raise rides the driver-health snapshot to the AdminUI `/hosts` page as a **re-browse chip** — but the
+> served address space still comes from the deploy artifact, so **a DBIRTH introducing a new metric does
+> not change the OPC UA tree**. Re-browse the device and commit. That is deliberate: v3 authors raw tags
+> through the `/raw` browse-commit flow, and grafting nodes at runtime would materialise ones nobody
+> approved and no deployment artifact records.
+
+## Structured metrics (`Template` / `DataSet` / `PropertySet`)
+
+Sparkplug metrics are not all scalars. `SparkplugFlattener`
+([#515](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/515)) turns the structured ones into
+addressable tags:
+
+| Metric kind | Mapping | Example |
+|---|---|---|
+| `Template` (UDT instance) | members become **sibling metrics**, `/`-joined, recursively | `Motor` → `Motor/Speed`, `Motor/Stator/Temp` |
+| `DataSet` | **one metric per cell**, `{column}/{rowIndex}` | `Recipe` → `Recipe/Step/0`, `Recipe/Target/1` |
+| `PropertySet` (a metric's `properties`) | node **metadata**, never tags | `engUnit`, `Range/Low` |
+
+Flattened members are authored exactly like any other Sparkplug metric — the composed name goes in
+`metricName`. The browse picker offers them directly (the structured **parent** is deliberately *not*
+offered: it has no value and no servable datatype, so a tag committed against it could never read).
+
+**Why flattening rather than OPC UA Structures.** Owner decision, 2026-07-31. Structures are truer to
+Sparkplug's model but much harder to consume — flattened scalars are readable by any OPC UA client
+with no type dictionary, and the result mirrors how the `/raw` tree already works.
+
+### Things worth knowing before you touch this
+
+- **The row index is always present, including on row 0.** `Recipe/Target/0`, never `Recipe/Target`.
+ Emitting a bare column name for a single-row table and an indexed one otherwise would make a tag's
+ name depend on how many rows the plant published — so a table growing from one row to two would
+ rename every tag already authored against it.
+- **A DATA metric carries the parent's alias and no name**, so member names cannot be composed until
+ the alias table has resolved the parent. Flattening at decode time alone is not enough; the
+ expansion happens in `SparkplugIngestor.PublishStructuredMembers`, after resolution.
+- **Member datatype falls back to the birth's.** A DDATA template may omit its members' datatypes.
+ Guessing from the CLR type of the wire value would read a signed `Int32` of −42 out of its unsigned
+ field as 4294967254 — plausible, `Good`, and wrong.
+- **Skip-and-warn survives, and refusal is whole.** A template **definition** (`is_definition`), an
+ unnamed member, an extension value, a column/type count mismatch, a short row, or a payload past
+ `MaxDepth` (5) / `MaxMembers` (512) refuses the **entire** metric rather than publishing part of it.
+ A silently half-flattened UDT looks exactly like a complete one in the picker.
+- **Member paths contain `/`**, which `RawBrowseCommitMapper.ToRawSegment`
+ ([#509](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/509)) already handles: the slashed name
+ travels intact as `metricName` while the derived tag **Name** is coerced to a legal RawPath segment,
+ with a collision guard for two members that would sanitise onto one name.
+- **The rediscovery signature tracks members, not the parent.** A UDT gaining or losing a member leaves
+ the parent metric's name identical, so a gate built on raw wire names would never move.
+
+`PropertySet` is projected as metadata rather than as sibling tags because it *describes* a metric
+(units, ranges, quality hints) — surfacing it as tags would invent plant signals out of documentation.
+`PropertySetList` and property extension values are dropped rather than stringified.
+
+## Primary host (`actAsPrimaryHost`)
+
+A Sparkplug **Primary Host Application** is the thing edge nodes watch to decide whether anyone is
+listening — many stop publishing while their host is offline. Claiming it is a three-part contract,
+and this driver ships all three or none of it
+([#511](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/511)):
+
+1. a **retained** `{"online":true,"timestamp":…}` on `spBv1.0/STATE/{hostId}`, QoS 1, published after CONNACK;
+2. a matching **retained `OFFLINE` armed as the MQTT Last Will**, so a crash still reports the truth;
+3. an explicit `OFFLINE` on a clean stand-down, so edge nodes do not wait out a keep-alive timeout.
+
+Set `Sparkplug.HostId` and `Sparkplug.ActAsPrimaryHost: true`. A blank host id is **refused** with a
+warning — the driver does not publish to `spBv1.0/STATE/`, which is a legal topic reaching nobody.
+
+### The claim is a whole connection, not a message
+
+A Will can only be registered in the **CONNECT packet**. It cannot be added to a live session, changed,
+or withdrawn. So the claim gets its **own** broker connection (client id `{clientId}-spbhost`, forced
+clean-session) whose lifetime *is* the claim: opened on gaining Primary, closed on losing it. Every leg
+then follows from the connection's lifecycle rather than from bookkeeping that could drift.
+
+The ingest session is untouched by a role change — no dropped subscriptions, no lost birth cache, and
+no data gap at the Secondary→Primary transition, which is the moment a plant most needs values flowing.
+
+### Primary-gated — and why the obvious alternatives are wrong
+
+Both nodes of a redundant pair subscribe independently (MQTT fan-out is per-connection, and the Primary
+gate applies downstream of ingest), so an ungated claim means both claim the same host id. The sharp
+failure is not the two fighting over the retained `STATE`. It is that the **Secondary's** death fires
+*its* Will, publishing `OFFLINE` while the Primary is perfectly healthy — every edge node in the group
+stops publishing, and redundancy has caused the outage the mechanism exists to prevent.
+
+Registering the Will only on whichever node is Primary *at connect time* fails the other way: a node
+that later gains Primary would publish `ONLINE` with no Will behind it, and a crash would leave a
+retained `ONLINE` asserting forever that a dead host is alive.
+
+Hence: bind the claim to a connection, and rebuild the connection on role change.
+
+| Node state | STATE connection | Retained `STATE` |
+|---|---|---|
+| Primary | open, Will armed | `ONLINE` |
+| Secondary / Detached | **never opened** | — (whatever the Primary published) |
+| Role unknown, driver peer present | not opened (fail closed) | — |
+| Role unknown, no driver peer (single node) | open | `ONLINE` |
+
+The verdict comes from `PrimaryGatePolicy.ShouldServiceAsPrimary` via `IDriverPrimaryRoleSource`
+(`Core.Abstractions`), published by `DriverHostActor` on every redundancy snapshot and **sampled on a
+1-second timer** rather than evented — a process-singleton raising events at per-instance drivers
+outlives every driver it holds a reference to.
+
+> **Accepted trade: a brief edge-visible `OFFLINE` during failover**, while the old Primary stands down
+> and the new one connects and republishes. That is a genuine state transition — for that window there
+> really is no host — not a glitch worth hiding.
+
+### Operational notes
+
+- **At most one primary host per host id per broker.** Two separate OtOpcUa deployments pointing at one
+ broker with the same `hostId` will fight, and nothing in either can detect the other. The gate covers
+ a redundant *pair*, not two independent deployments.
+- **A reconnect republishes the birth.** A broker restart drops retained messages; without the
+ republish the host would be connected and believed offline by everyone, silently.
+- **A failed claim never faults the driver.** A broker whose ACL denies `spBv1.0/STATE/#` costs a
+ warning and a plant-visible missing host, not every tag's value. Ingest is the data plane; the claim
+ is an assertion to other applications.
+- **The driver still observes its own `STATE`.** The ingestor subscribes the configured host id
+ regardless, so a claiming node sees its own retained `ONLINE` come back. Nothing gates on it, so the
+ echo costs one log line.
## Connection + Failure Semantics
@@ -446,9 +569,14 @@ identical words. See [TestConnectProbes.md](TestConnectProbes.md).
reconnect/rebuild branches, CONNACK classification, and the Sparkplug half — golden-payload decode,
topic parse/format, datatype map, alias/birth-cache rebuild, sequence + bdSeq-death ties, the
rediscovery change gate, and the browse session's "publishes nothing" assertion.
-- **Live (env-gated)** — `tests/Drivers/…Driver.Mqtt.IntegrationTests` (**15 tests**) against the
+- **Live (env-gated)** — `tests/Drivers/…Driver.Mqtt.IntegrationTests` (**29 tests**) against the
Mosquitto broker + the C# Sparkplug edge-node simulator; skips cleanly when `MQTT_FIXTURE_ENDPOINT`
is unset. See [Mqtt-Test-Fixture.md](Mqtt-Test-Fixture.md) and `infra/README.md` §3.
+ The primary-host legs (`MqttPrimaryHostLiveTests`) include the one the ordinary lifecycle cannot
+ reach: `AbandonClaimForTests` drops the socket with **no** `DISCONNECT`, because a clean disconnect
+ makes the broker *discard* the Will by protocol rule — so a test driven through `StopAsync` would
+ observe the explicit stand-down `OFFLINE` and pass against an implementation that never armed a Will
+ at all.
- **AdminUI** — `MqttDriverFormModelTests` / `MqttTagConfigModelTests` cover both editors'
round-trip, mode inference and validation. AdminUI has no bUnit, so the razor shells themselves are
only ever proven by a live `/run` gate.
@@ -466,7 +594,9 @@ identical words. See [TestConnectProbes.md](TestConnectProbes.md).
Every row is tracked. None is a silent failure — each either refuses in words or logs.
-**Closed 2026-07-31:** write-through ([#508](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/508), live-gated), the slashed-metric browse-commit
+**Closed 2026-07-31:** write-through ([#508](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/508), live-gated), primary-host STATE publishing
+([#511](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/511), live-gated), structured
+`Template`/`DataSet`/`PropertySet` metrics ([#515](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/515), live-gated), the slashed-metric browse-commit
([#509](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/509)), the stale rebirth affordance ([#510](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/510)), the `.Browser` layering exception
([#512](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/512)), the stray Sparkplug `payloadFormat` ([#513](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/513)) and rebirth on a
not-yet-birthed plant ([#514](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/514)).
@@ -474,5 +604,3 @@ not-yet-birthed plant ([#514](https://gitea.dohertylan.com/dohertj2/lmxopcua/iss
| Gap | Issue | Effect | Notes |
|---|:---:|---|---|
| **Rediscovery is advisory, not a rebuild** | [#507](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/507) (closed, superseded) | A DBIRTH introducing a metric does not change the served OPC UA tree; an operator must re-browse the device and commit | **No longer "inert".** `DriverInstanceActor` now subscribes to `OnRediscoveryNeeded` and the raise rides the driver-health snapshot to the AdminUI `/hosts` page as a **re-browse chip**. It deliberately does not inject nodes: v3 authors raw tags through the `/raw` browse-commit flow, and grafting nodes at runtime would materialise ones nobody approved and no deployment artifact records. #507's injection path was deleted rather than revived |
-| **Primary-host STATE publishing** | [#511](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/511) | `ActAsPrimaryHost` does nothing | Logs a startup warning rather than being silently inert — see the `Sparkplug` sub-object table |
-| **`DataSet` / `Template` / `PropertySet` metrics** | [#515](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/515) | Skipped with a warning | v1-unsupported; never coerced into a guessed type |
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverPrimaryRoleSource.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverPrimaryRoleSource.cs
new file mode 100644
index 00000000..b2780389
--- /dev/null
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverPrimaryRoleSource.cs
@@ -0,0 +1,90 @@
+namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+///
+/// A driver-facing snapshot of "should this node be the one asserting an exclusive identity on a
+/// shared external system right now?".
+///
+///
+///
+/// Why drivers need this at all. The driver layer has no notion of redundancy, by
+/// design — a driver talks to a device and the Primary gate is applied above it, in
+/// DriverHostActor, on the way in. That works because every gated operation is
+/// inbound: a write, an alarm ack, an alerts publish. A Sparkplug primary-host claim is
+/// not inbound. It is a standing assertion the driver makes to the broker, on its own
+/// connection, for as long as it holds one — there is no message for the host actor to
+/// intercept. So the decision has to reach the driver instead (Gitea #511).
+///
+///
+/// The decision, not the role. Same discipline as the alarm-drain view in
+/// Runtime: this publishes the verdict a single policy produced, so the policy stays the
+/// one place that decides and a driver can never re-derive a subtly different rule from a raw
+/// role. The verdict fed here is the data-plane one (an unknown role on a cluster that
+/// has a driver peer denies), because two nodes claiming one Sparkplug host id is the harm the
+/// gate exists to prevent — not a duplicate row, but a Secondary's Last Will publishing
+/// OFFLINE over a live Primary's ONLINE and stopping every edge node in the
+/// group.
+///
+///
+/// Polled, not evented, and that is deliberate. A process-wide singleton raising an
+/// event onto per-instance drivers outlives every driver it hands a reference to — the exact
+/// missing--= leak shape the rediscovery signal had to be careful about. Consumers
+/// sample this instead, so a driver that is torn down simply stops asking. The cost is up to
+/// one poll interval of latency on a failover, which is inside the edge-visible
+/// OFFLINE window a failover already implies.
+///
+///
+public interface IDriverPrimaryRoleSource
+{
+ ///
+ /// Whether this node currently holds the Primary data-plane role — and may therefore claim an
+ /// exclusive external identity such as a Sparkplug host id.
+ ///
+ bool ShouldActAsPrimary { get; }
+
+ ///
+ /// Records a fresh decision. Called by DriverHostActor on every redundancy snapshot.
+ ///
+ ///
+ /// The verdict PrimaryGatePolicy.ShouldServiceAsPrimary produced.
+ ///
+ void PublishPrimaryRole(bool shouldActAsPrimary);
+}
+
+///
+/// The a driver uses when nothing publishes one — a
+/// standalone host, a unit test, or any deployment with no redundancy manager wired.
+///
+///
+///
+/// It answers , and the alternative is worse. Defaulting closed
+/// would be the reflexive "fail-safe" choice, but there is no safety to gain: with nothing
+/// publishing a role there is by definition no peer to collide with, and a closed default
+/// would make an exclusive-identity claim silently never happen on the single-node deployment
+/// that is the common case — an inert feature with no error, which is precisely the failure
+/// mode Gitea #511 was filed about.
+///
+///
+/// The risk this shifts onto the wiring is real and is guarded there instead: a redundant
+/// deployment whose composition root forgets to supply the live source would fall back to this
+/// and let both nodes claim. DriverFactoryBootstrapPrimaryRoleSourceTests pins that the
+/// Host resolves a real one.
+///
+///
+public sealed class UnmanagedDriverPrimaryRoleSource : IDriverPrimaryRoleSource
+{
+ /// The shared instance — it holds no state.
+ public static readonly UnmanagedDriverPrimaryRoleSource Instance = new();
+
+ private UnmanagedDriverPrimaryRoleSource()
+ {
+ }
+
+ ///
+ public bool ShouldActAsPrimary => true;
+
+ ///
+ /// No-op: there is no redundancy manager to publish anything here.
+ public void PublishPrimaryRole(bool shouldActAsPrimary)
+ {
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs
index cb240b42..2e37d579 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs
@@ -585,20 +585,77 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
continue;
}
- var metricPath = ownerPath + MetricSeparator + name;
- if (metricPath.Length > MaxTopicChars)
+ // Gitea #515: a Template/DataSet metric contributes its flattened MEMBERS as leaves, never
+ // itself. The parent has no value and no servable datatype, so a leaf for it would offer
+ // the operator an address that can only ever commit a tag which never reads.
+ if (metric.ValueKind == SparkplugValueKind.Structured)
{
- Volatile.Write(ref _oversized, 1);
+ foreach (var member in metric.MemberList)
+ {
+ var memberName = $"{name}{SparkplugFlattener.MemberSeparator}{member.Path}";
+ if (!IsUsableLabel(memberName))
+ {
+ Volatile.Write(ref _oversized, 1);
+ continue;
+ }
+
+ if (!AddMetricLeaf(owner, ownerPath, ownerScope, memberName, member.DataType, parsed.Type, alias: null))
+ {
+ return; // node cap tripped
+ }
+ }
+
continue;
}
- var node = GetOrCreate(owner.Children, metricPath, name, BrowseNodeKind.Leaf, ownerScope);
- if (node is null) return; // node cap tripped
-
- node.RecordMetric(new SparkplugMetricInfo(name, metric.DataType, parsed.Type, metric.Alias));
+ if (!AddMetricLeaf(owner, ownerPath, ownerScope, name, metric.DataType, parsed.Type, metric.Alias))
+ {
+ return; // node cap tripped
+ }
}
}
+ ///
+ /// Places one metric leaf under .
+ ///
+ /// The owning edge-node / device folder.
+ /// That folder's path.
+ /// The scope stamped on the leaf.
+ ///
+ /// The metric's Sparkplug name — for a flattened structured member, the composed
+ /// {parent}/{memberPath}. It travels intact: it is the binding key, and only the
+ /// display name is later coerced to a legal RawPath segment (Gitea #509).
+ ///
+ /// The declared datatype, or null.
+ /// The birth message type this leaf was seen in.
+ /// The metric's alias, or null — flattened members carry none of their own.
+ /// when the node cap tripped and the caller must stop.
+ private bool AddMetricLeaf(
+ TopicNode owner,
+ string ownerPath,
+ SparkplugScope ownerScope,
+ string metricName,
+ Org.Eclipse.Tahu.Protobuf.DataType? dataType,
+ SparkplugMessageType messageType,
+ ulong? alias)
+ {
+ var metricPath = ownerPath + MetricSeparator + metricName;
+ if (metricPath.Length > MaxTopicChars)
+ {
+ Volatile.Write(ref _oversized, 1);
+ return true;
+ }
+
+ var node = GetOrCreate(owner.Children, metricPath, metricName, BrowseNodeKind.Leaf, ownerScope);
+ if (node is null)
+ {
+ return false;
+ }
+
+ node.RecordMetric(new SparkplugMetricInfo(metricName, dataType, messageType, alias));
+ return true;
+ }
+
///
/// Finds or creates one tree node under , honouring the node cap.
///
@@ -698,6 +755,40 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
ObserveBirth(groupId, edgeNodeId, device, metric);
}
+ ///
+ /// Test seam feeding a birth whose metric is a structured Template (Gitea #515).
+ ///
+ /// The Sparkplug group id.
+ /// The Sparkplug edge-node id.
+ /// The device id, or for an NBIRTH.
+ /// The template metric's own name.
+ /// The template's members, as (name, datatype).
+ internal void ObserveTemplateBirthForTest(
+ string groupId,
+ string edgeNodeId,
+ string? device,
+ string metricName,
+ params (string Name, SparkplugDataType DataType)[] members)
+ {
+ var template = new Payload.Types.Template { Version = "1", IsDefinition = false };
+ foreach (var (name, dataType) in members)
+ {
+ var member = new Payload.Types.Metric { Name = name, Datatype = (uint)dataType };
+
+ // A value is required: a member carrying only a name and a datatype is indistinguishable
+ // from a template DEFINITION's member, which the flattener deliberately refuses.
+ member.DoubleValue = 0;
+ template.Metrics.Add(member);
+ }
+
+ ObserveBirth(groupId, edgeNodeId, device, new Payload.Types.Metric
+ {
+ Name = metricName,
+ Datatype = (uint)SparkplugDataType.Template,
+ TemplateValue = template,
+ });
+ }
+
/// Test seam feeding a birth certificate that declares no metrics at all.
/// The Sparkplug group id.
/// The Sparkplug edge-node id.
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttDriverOptions.cs
index 8acb9148..c40bae79 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttDriverOptions.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttDriverOptions.cs
@@ -190,6 +190,12 @@ public sealed record MqttSparkplugOptions
/// When true, this driver instance acts as the Sparkplug Primary Host
/// Application. At most one primary host may exist per host-id per broker.
///
+ ///
+ /// Implemented by SparkplugPrimaryHost (Gitea #511) on a dedicated broker connection
+ /// whose lifetime is the claim, and gated on the redundancy Primary role so the two nodes
+ /// of a pair never both claim. Requires ; set without one, the driver warns
+ /// and declines rather than publishing to a malformed topic.
+ ///
public bool ActAsPrimaryHost { get; init; } = false;
///
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttLastWill.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttLastWill.cs
new file mode 100644
index 00000000..a65a905d
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttLastWill.cs
@@ -0,0 +1,37 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
+
+///
+/// An MQTT Last Will and Testament, as the client options factory wants it (Gitea #511).
+///
+///
+///
+/// A Will can only be registered at CONNECT, and that single fact drives the whole design
+/// of the Sparkplug primary-host claim. It rides in the CONNECT packet, so it cannot be
+/// added, changed or withdrawn on a live session. A node that gains the Primary role after
+/// connecting therefore cannot simply start publishing ONLINE — it would have no Will,
+/// and a crash would leave a retained ONLINE asserting forever that a dead host is
+/// alive. Equally, a node that registers a Will and later loses the role cannot withdraw it,
+/// so its eventual disconnect would publish OFFLINE over the new Primary's live claim
+/// and stop every edge node in the group.
+///
+///
+/// The consequence is that the claim must be bound to the lifetime of a connection:
+/// SparkplugPrimaryHost opens a dedicated connection when the node gains Primary and
+/// closes it when the node loses Primary, rather than trying to mutate the ingest session it
+/// shares with the rest of the driver.
+///
+///
+/// Clean session matters too. A broker that resumes an existing session for the same
+/// client id may discard the Will registered by the new CONNECT. The primary-host connection
+/// forces clean-session on for that reason, regardless of the driver's configured value.
+///
+///
+/// The topic the broker publishes the Will to.
+/// The Will body.
+/// Requested QoS, 0–2.
+///
+/// Whether the broker retains the Will. For a Sparkplug host death certificate this is
+/// : an edge node that connects after the host died must still learn
+/// the host is offline, and only a retained message reaches it.
+///
+public sealed record MqttLastWill(string Topic, byte[] Payload, int Qos, bool Retain);
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/Sparkplug/SparkplugCodec.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/Sparkplug/SparkplugCodec.cs
index b428ee19..309aa3c1 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/Sparkplug/SparkplugCodec.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/Sparkplug/SparkplugCodec.cs
@@ -45,10 +45,19 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
/// wire, not of the tag.
///
///
-/// DataSet and Template metrics are out of scope for v1. They decode to
+/// Template and DataSet metrics decode to
+/// with their members flattened into
+/// (Gitea #515) — a template member Speed of metric
+/// Motor becomes the addressable Motor/Speed. See
+/// for the mapping and its bounds. Anything that cannot be flattened honestly stays
/// with a null value — deliberately visible, so a
-/// consumer can warn about a metric it cannot serve instead of silently treating it as one
-/// that was never published.
+/// consumer warns about a metric it cannot serve instead of silently treating it as one that was
+/// never published, and never coerces it into a guessed scalar.
+///
+///
+/// A metric's PropertySet is projected to
+/// as node metadata, never as sibling values: a PropertySet describes a metric (units, ranges,
+/// quality hints); it is not something the metric is.
///
///
public static class SparkplugCodec
@@ -161,7 +170,7 @@ public static class SparkplugCodec
/// The projection.
private static SparkplugMetric ProjectMetric(Payload.Types.Metric metric)
{
- var (kind, value) = ProjectValue(metric);
+ var (kind, value, members) = ProjectValue(metric);
return new SparkplugMetric(
Name: metric.HasName ? metric.Name : null,
@@ -173,7 +182,22 @@ public static class SparkplugCodec
DataType: metric.HasDatatype ? (TahuDataType)metric.Datatype : null,
TimestampMs: metric.HasTimestamp ? metric.Timestamp : null,
ValueKind: kind,
- Value: value);
+ Value: value,
+ Members: members,
+ Properties: SparkplugFlattener.ProjectProperties(metric.Properties));
+ }
+
+ ///
+ /// Resolves a template member's value oneof. Internal to the assembly: shared with
+ /// , which walks nested templates and needs the identical
+ /// projection so a member and a top-level metric of the same type never decode differently.
+ ///
+ /// The template member, itself a generated Metric.
+ /// The value kind and the projected raw value.
+ internal static (SparkplugValueKind Kind, object? Value) ProjectMemberValue(Payload.Types.Metric metric)
+ {
+ var (kind, value, _) = ProjectValue(metric);
+ return (kind, value);
}
/// Resolves a metric's value oneof to a kind plus a raw CLR value.
@@ -184,34 +208,52 @@ public static class SparkplugCodec
/// that some datatypes have no spare sentinel, so a publisher that sets it means "null" even if
/// a value field is also populated.
///
- private static (SparkplugValueKind Kind, object? Value) ProjectValue(Payload.Types.Metric metric)
+ private static (SparkplugValueKind Kind, object? Value, IReadOnlyList Members) ProjectValue(
+ Payload.Types.Metric metric)
{
if (metric.HasIsNull && metric.IsNull)
{
- return (SparkplugValueKind.Null, null);
+ return (SparkplugValueKind.Null, null, []);
+ }
+
+ // Gitea #515: structured values flatten to addressable members. A refusal to flatten falls
+ // through to Unsupported — the pre-#515 behaviour — rather than to a guessed scalar.
+ switch (metric.ValueCase)
+ {
+ case Payload.Types.Metric.ValueOneofCase.TemplateValue:
+ return SparkplugFlattener.TryFlattenTemplate(metric.TemplateValue, out var templateMembers)
+ ? (SparkplugValueKind.Structured, null, templateMembers)
+ : (SparkplugValueKind.Unsupported, null, []);
+
+ case Payload.Types.Metric.ValueOneofCase.DatasetValue:
+ return SparkplugFlattener.TryFlattenDataSet(metric.DatasetValue, out var cells)
+ ? (SparkplugValueKind.Structured, null, cells)
+ : (SparkplugValueKind.Unsupported, null, []);
}
return metric.ValueCase switch
{
- Payload.Types.Metric.ValueOneofCase.IntValue => (SparkplugValueKind.Scalar, metric.IntValue),
- Payload.Types.Metric.ValueOneofCase.LongValue => (SparkplugValueKind.Scalar, metric.LongValue),
- Payload.Types.Metric.ValueOneofCase.FloatValue => (SparkplugValueKind.Scalar, metric.FloatValue),
- Payload.Types.Metric.ValueOneofCase.DoubleValue => (SparkplugValueKind.Scalar, metric.DoubleValue),
- Payload.Types.Metric.ValueOneofCase.BooleanValue => (SparkplugValueKind.Scalar, metric.BooleanValue),
- Payload.Types.Metric.ValueOneofCase.StringValue => (SparkplugValueKind.Scalar, metric.StringValue),
+ Payload.Types.Metric.ValueOneofCase.IntValue => (SparkplugValueKind.Scalar, metric.IntValue, Empty),
+ Payload.Types.Metric.ValueOneofCase.LongValue => (SparkplugValueKind.Scalar, metric.LongValue, Empty),
+ Payload.Types.Metric.ValueOneofCase.FloatValue => (SparkplugValueKind.Scalar, metric.FloatValue, Empty),
+ Payload.Types.Metric.ValueOneofCase.DoubleValue => (SparkplugValueKind.Scalar, metric.DoubleValue, Empty),
+ Payload.Types.Metric.ValueOneofCase.BooleanValue => (SparkplugValueKind.Scalar, metric.BooleanValue, Empty),
+ Payload.Types.Metric.ValueOneofCase.StringValue => (SparkplugValueKind.Scalar, metric.StringValue, Empty),
// Copied out of the ByteString: the projection must outlive the parsed message.
Payload.Types.Metric.ValueOneofCase.BytesValue =>
- (SparkplugValueKind.Scalar, metric.BytesValue.ToByteArray()),
+ (SparkplugValueKind.Scalar, metric.BytesValue.ToByteArray(), Empty),
- // v1 scope boundary — decoded as a visible refusal, not as an exception or a silent drop.
- Payload.Types.Metric.ValueOneofCase.DatasetValue => (SparkplugValueKind.Unsupported, null),
- Payload.Types.Metric.ValueOneofCase.TemplateValue => (SparkplugValueKind.Unsupported, null),
- Payload.Types.Metric.ValueOneofCase.ExtensionValue => (SparkplugValueKind.Unsupported, null),
+ // An extension value is a third-party escape hatch with no defined semantics — there is
+ // nothing honest to flatten it into.
+ Payload.Types.Metric.ValueOneofCase.ExtensionValue => (SparkplugValueKind.Unsupported, null, Empty),
- _ => (SparkplugValueKind.Absent, null),
+ _ => (SparkplugValueKind.Absent, null, Empty),
};
}
+
+ /// The shared empty member list — every scalar metric would otherwise allocate one.
+ private static readonly IReadOnlyList Empty = [];
}
///
@@ -279,13 +321,31 @@ public sealed record SparkplugPayload(
/// switch over a dozen arms.
///
///
+///
+/// The flattened members of a metric — a
+/// Template or DataSet broken into addressable scalars (Gitea #515). Empty for every
+/// other kind. Paths are relative to this metric; see .
+///
+///
+/// The metric's PropertySet, projected as node metadata (Gitea #515). Empty when the metric
+/// carried none. Never a value — a PropertySet describes the metric rather than being one.
+///
public readonly record struct SparkplugMetric(
string? Name,
ulong? Alias,
TahuDataType? DataType,
ulong? TimestampMs,
SparkplugValueKind ValueKind,
- object? Value);
+ object? Value,
+ IReadOnlyList? Members = null,
+ IReadOnlyList? Properties = null)
+{
+ /// The flattened members, never null.
+ public IReadOnlyList MemberList => Members ?? [];
+
+ /// The projected properties, never null.
+ public IReadOnlyList PropertyList => Properties ?? [];
+}
/// What a decoded metric's represents.
///
@@ -305,9 +365,17 @@ public enum SparkplugValueKind
Scalar,
///
- /// A DataSet, Template or extension value — decoded and reported, but not
- /// supported in v1. The consumer should warn and skip the metric rather than treat it as
- /// missing.
+ /// An extension value, or a Template/DataSet that could not be flattened
+ /// honestly (see ). Decoded and reported, but not servable:
+ /// the consumer warns and skips the metric rather than treating it as missing — and never
+ /// coerces it into a guessed scalar.
///
Unsupported,
+
+ ///
+ /// A Template or DataSet whose members were flattened into
+ /// (Gitea #515). is
+ /// null — the parent has no scalar value of its own; its members carry the values.
+ ///
+ Structured,
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/Sparkplug/SparkplugFlattener.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/Sparkplug/SparkplugFlattener.cs
new file mode 100644
index 00000000..7cb81bb8
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/Sparkplug/SparkplugFlattener.cs
@@ -0,0 +1,368 @@
+using Org.Eclipse.Tahu.Protobuf;
+using TahuDataType = Org.Eclipse.Tahu.Protobuf.DataType;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
+
+///
+/// Flattens Sparkplug's structured metric values — Template (UDT instances) and
+/// DataSet — into addressable scalar members, and projects a metric's PropertySet
+/// into node metadata (Gitea #515).
+///
+///
+///
+/// Flatten to siblings, by owner decision (2026-07-31). The alternative was mapping a
+/// Template to an OPC UA Structure/UDT, which is truer to Sparkplug's model but far harder to
+/// consume: flattened scalars are readable by any OPC UA client (ScadaBridge included) with no
+/// type dictionary, and the result mirrors how the /raw tree already works. A member of
+/// template metric Motor called Speed becomes the metric Motor/Speed.
+///
+///
+/// Member paths contain /, which is already solved. That interaction is what
+/// #515 flagged against #509, and #509 shipped first:
+/// RawBrowseCommitMapper.ToRawSegment coerces the tag Name to a legal RawPath
+/// segment while carrying the original slashed string through as metricName, plus a
+/// collision guard that refuses two members which would sanitise onto one name. So the
+/// flattened name travels intact as the binding key and only the display name is coerced.
+///
+///
+/// Skip-and-warn survives, and that is a requirement rather than a leftover. Anything
+/// this cannot flatten honestly — an extension value, a nested member with no name, a payload
+/// past the bounds below — stays and is skipped
+/// with a warning. A structured value coerced into a guessed scalar would bind a tag to data
+/// that is not what its name says, which is worse than not binding it at all.
+///
+///
+/// Bounded, because this walks an attacker-shaped object graph. A Template may contain
+/// Templates, so the natural recursion is unbounded in a format anyone on the broker can
+/// publish. and cap it; exceeding either
+/// abandons the whole metric to Unsupported rather than publishing a truncated member
+/// set, because a silently half-flattened UDT looks exactly like a complete one.
+///
+///
+/// Nothing here throws — same contract as , for the same
+/// reason: this runs on MQTTnet's shared dispatcher thread behind an unauthenticated firehose.
+///
+///
+public static class SparkplugFlattener
+{
+ ///
+ /// How deeply nested a Template may be before the whole metric is refused. Five is far past
+ /// anything a real UDT library produces and still bounds the walk.
+ ///
+ public const int MaxDepth = 5;
+
+ ///
+ /// Ceiling on the flattened members one metric may produce. A DataSet is columns × rows, so a
+ /// modest-looking table is the realistic way to reach this.
+ ///
+ public const int MaxMembers = 512;
+
+ ///
+ /// Separator between a structured metric's name and its member path. The same character
+ /// Sparkplug itself uses for metric-name hierarchy, so Motor/Speed is what an edge node
+ /// would have called a flat metric of the same meaning.
+ ///
+ public const char MemberSeparator = '/';
+
+ ///
+ /// Flattens a Template into member paths relative to the owning metric.
+ ///
+ /// The template value.
+ /// The flattened members on success; empty otherwise.
+ ///
+ /// when the whole template flattened. means the
+ /// caller must treat the metric as .
+ ///
+ public static bool TryFlattenTemplate(Payload.Types.Template template, out IReadOnlyList members)
+ {
+ var collected = new List();
+ var ok = template is not null && AppendTemplate(template, prefix: "", depth: 1, collected);
+ members = ok ? collected : [];
+ return ok;
+ }
+
+ ///
+ /// Flattens a DataSet into one member per cell, named {column}/{rowIndex}.
+ ///
+ /// The dataset value.
+ /// The flattened members on success; empty otherwise.
+ ///
+ /// when the whole dataset flattened. means the
+ /// caller must treat the metric as .
+ ///
+ ///
+ ///
+ /// Per cell, not per column — and the row index is always present. The recorded
+ /// decision says "per-column tags, not one array-valued variable", and a column of N rows
+ /// is not a scalar, so per-column can only mean per-cell once N > 1. Emitting a bare
+ /// {column} for the single-row case and {column}/{row} otherwise would make
+ /// a tag's own name depend on how many rows the plant happened to publish — so a table
+ /// that grew from one row to two would rename every existing tag out from under its
+ /// authored binding. Uniform naming costs a /0 on the common single-row "current
+ /// values" shape and buys a stable address.
+ ///
+ ///
+ /// A row that disappears simply stops publishing, and its tag holds its last value — the
+ /// same as any metric an edge node stops sending.
+ ///
+ ///
+ public static bool TryFlattenDataSet(Payload.Types.DataSet dataSet, out IReadOnlyList members)
+ {
+ members = [];
+ if (dataSet is null)
+ {
+ return false;
+ }
+
+ // Columns and types are parallel arrays. A publisher that disagrees with itself has produced a
+ // dataset whose cells cannot be typed, and guessing which array to trust would type a column
+ // by its neighbour — refuse instead.
+ if (dataSet.Columns.Count == 0 || dataSet.Types_.Count != dataSet.Columns.Count)
+ {
+ return false;
+ }
+
+ if ((long)dataSet.Columns.Count * dataSet.Rows.Count > MaxMembers)
+ {
+ return false;
+ }
+
+ var collected = new List();
+ for (var row = 0; row < dataSet.Rows.Count; row++)
+ {
+ var elements = dataSet.Rows[row].Elements;
+
+ // A short row is a malformed dataset, not a row of nulls: emitting the cells that ARE
+ // present would publish a table silently missing its right-hand columns.
+ if (elements.Count != dataSet.Columns.Count)
+ {
+ return false;
+ }
+
+ for (var col = 0; col < dataSet.Columns.Count; col++)
+ {
+ var column = dataSet.Columns[col];
+ if (string.IsNullOrEmpty(column))
+ {
+ return false; // An unnamed column has no address.
+ }
+
+ var (kind, value) = ProjectDataSetValue(elements[col]);
+ collected.Add(new SparkplugMember(
+ Path: $"{column}{MemberSeparator}{row.ToString(System.Globalization.CultureInfo.InvariantCulture)}",
+ DataType: (TahuDataType)dataSet.Types_[col],
+ ValueKind: kind,
+ Value: value));
+ }
+ }
+
+ members = collected;
+ return true;
+ }
+
+ ///
+ /// Projects a metric's PropertySet into flat name/value metadata.
+ ///
+ /// The property set, or .
+ /// The projected properties; empty when there are none or the set is malformed.
+ ///
+ ///
+ /// Metadata, never tags — the recorded decision, and the honest reading of the
+ /// spec: a PropertySet describes a metric (engineering units, ranges, quality hints), it is
+ /// not a value the metric has. Surfacing it as sibling tags would invent plant signals out
+ /// of documentation.
+ ///
+ ///
+ /// Nested PropertySet/PropertySetList values are flattened one level with a
+ /// /-joined key, matching the metric-member convention; anything deeper, or an
+ /// extension value, is dropped rather than stringified.
+ ///
+ ///
+ public static IReadOnlyList ProjectProperties(Payload.Types.PropertySet? properties)
+ {
+ if (properties is null || properties.Keys.Count == 0 || properties.Keys.Count != properties.Values.Count)
+ {
+ return [];
+ }
+
+ var collected = new List();
+ AppendProperties(properties, prefix: "", depth: 1, collected);
+ return collected;
+ }
+
+ private static void AppendProperties(
+ Payload.Types.PropertySet set, string prefix, int depth, List into)
+ {
+ if (set.Keys.Count != set.Values.Count || depth > 2 || into.Count >= MaxMembers)
+ {
+ return;
+ }
+
+ for (var i = 0; i < set.Keys.Count && into.Count < MaxMembers; i++)
+ {
+ var key = set.Keys[i];
+ if (string.IsNullOrEmpty(key))
+ {
+ continue;
+ }
+
+ var name = prefix.Length == 0 ? key : $"{prefix}{MemberSeparator}{key}";
+ var value = set.Values[i];
+
+ if (value.HasIsNull && value.IsNull)
+ {
+ into.Add(new SparkplugProperty(name, null));
+ continue;
+ }
+
+ switch (value.ValueCase)
+ {
+ case Payload.Types.PropertyValue.ValueOneofCase.IntValue:
+ into.Add(new SparkplugProperty(name, value.IntValue));
+ break;
+ case Payload.Types.PropertyValue.ValueOneofCase.LongValue:
+ into.Add(new SparkplugProperty(name, value.LongValue));
+ break;
+ case Payload.Types.PropertyValue.ValueOneofCase.FloatValue:
+ into.Add(new SparkplugProperty(name, value.FloatValue));
+ break;
+ case Payload.Types.PropertyValue.ValueOneofCase.DoubleValue:
+ into.Add(new SparkplugProperty(name, value.DoubleValue));
+ break;
+ case Payload.Types.PropertyValue.ValueOneofCase.BooleanValue:
+ into.Add(new SparkplugProperty(name, value.BooleanValue));
+ break;
+ case Payload.Types.PropertyValue.ValueOneofCase.StringValue:
+ into.Add(new SparkplugProperty(name, value.StringValue));
+ break;
+ case Payload.Types.PropertyValue.ValueOneofCase.PropertysetValue:
+ AppendProperties(value.PropertysetValue, name, depth + 1, into);
+ break;
+ default:
+ // PropertySetList and extension values are dropped rather than stringified — a
+ // property whose displayed value is not what its type says is worse than an absent
+ // one, and these are documentation, not signal.
+ break;
+ }
+ }
+ }
+
+ /// Walks one template level, appending members under .
+ /// as soon as anything cannot be flattened honestly.
+ private static bool AppendTemplate(
+ Payload.Types.Template template, string prefix, int depth, List into)
+ {
+ if (depth > MaxDepth)
+ {
+ return false;
+ }
+
+ // A template DEFINITION carries the UDT's shape with no instance values. It is metadata an
+ // edge node publishes so consumers can understand instances — flattening it would materialise
+ // a parallel set of tags that never carry a value.
+ if (template.HasIsDefinition && template.IsDefinition)
+ {
+ return false;
+ }
+
+ foreach (var member in template.Metrics)
+ {
+ if (into.Count >= MaxMembers)
+ {
+ return false;
+ }
+
+ if (!member.HasName || string.IsNullOrEmpty(member.Name))
+ {
+ // A template member is addressed by name; there is no alias fallback inside a template.
+ return false;
+ }
+
+ var path = prefix.Length == 0 ? member.Name : $"{prefix}{MemberSeparator}{member.Name}";
+
+ if (member.ValueCase == Payload.Types.Metric.ValueOneofCase.TemplateValue)
+ {
+ if (!AppendTemplate(member.TemplateValue, path, depth + 1, into))
+ {
+ return false;
+ }
+
+ continue;
+ }
+
+ if (member.ValueCase == Payload.Types.Metric.ValueOneofCase.DatasetValue)
+ {
+ if (!TryFlattenDataSet(member.DatasetValue, out var cells)
+ || into.Count + cells.Count > MaxMembers)
+ {
+ return false;
+ }
+
+ foreach (var cell in cells)
+ {
+ into.Add(cell with { Path = $"{path}{MemberSeparator}{cell.Path}" });
+ }
+
+ continue;
+ }
+
+ var (kind, value) = SparkplugCodec.ProjectMemberValue(member);
+ if (kind == SparkplugValueKind.Unsupported)
+ {
+ return false; // An extension value inside a template: refuse the whole metric.
+ }
+
+ into.Add(new SparkplugMember(
+ Path: path,
+ DataType: member.HasDatatype ? (TahuDataType)member.Datatype : null,
+ ValueKind: kind,
+ Value: value));
+ }
+
+ return true;
+ }
+
+ private static (SparkplugValueKind Kind, object? Value) ProjectDataSetValue(
+ Payload.Types.DataSet.Types.DataSetValue value) => value.ValueCase switch
+ {
+ Payload.Types.DataSet.Types.DataSetValue.ValueOneofCase.IntValue => (SparkplugValueKind.Scalar, value.IntValue),
+ Payload.Types.DataSet.Types.DataSetValue.ValueOneofCase.LongValue => (SparkplugValueKind.Scalar, value.LongValue),
+ Payload.Types.DataSet.Types.DataSetValue.ValueOneofCase.FloatValue => (SparkplugValueKind.Scalar, value.FloatValue),
+ Payload.Types.DataSet.Types.DataSetValue.ValueOneofCase.DoubleValue => (SparkplugValueKind.Scalar, value.DoubleValue),
+ Payload.Types.DataSet.Types.DataSetValue.ValueOneofCase.BooleanValue => (SparkplugValueKind.Scalar, value.BooleanValue),
+ Payload.Types.DataSet.Types.DataSetValue.ValueOneofCase.StringValue => (SparkplugValueKind.Scalar, value.StringValue),
+
+ // A DataSetValue has no is_null field, so an empty oneof IS the null cell — unlike a Metric,
+ // where Absent and explicit-null are genuinely different states.
+ _ => (SparkplugValueKind.Null, null),
+ };
+}
+
+///
+/// One flattened member of a structured Sparkplug metric (Gitea #515).
+///
+///
+/// The member path relative to the owning metric, /-joined — Speed for a
+/// top-level template member, Motor/Speed for a nested one, Temp/0 for a dataset
+/// cell. The consumer composes the full metric name as {parentName}/{Path}; it is kept
+/// relative here because a DATA metric carries only the parent's alias, so the parent's name is
+/// not known until the alias table has resolved it.
+///
+///
+/// The member's declared datatype, or when it carried none — in which case
+/// the consumer falls back to the datatype the birth installed for this member path.
+///
+/// What means; never Unsupported here.
+/// The raw wire value, with the same signed-integer caveat as a scalar metric.
+public readonly record struct SparkplugMember(
+ string Path,
+ TahuDataType? DataType,
+ SparkplugValueKind ValueKind,
+ object? Value);
+
+/// One entry of a metric's PropertySet, projected as node metadata (Gitea #515).
+/// The property key, /-joined for a nested set.
+///
+/// The boxed scalar value, or for an explicitly-null property.
+///
+public readonly record struct SparkplugProperty(string Name, object? Value);
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/Sparkplug/SparkplugStatePayload.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/Sparkplug/SparkplugStatePayload.cs
new file mode 100644
index 00000000..1065f109
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/Sparkplug/SparkplugStatePayload.cs
@@ -0,0 +1,141 @@
+using System.Text;
+using System.Text.Json;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
+
+///
+/// Builds the Sparkplug v3.0 primary-host STATE bodies — the retained ONLINE birth
+/// and the matching OFFLINE death that is registered as the MQTT Last Will (Gitea #511).
+///
+///
+///
+/// Encode only. The receive side lives in SparkplugIngestor.TryReadStateOnline,
+/// which additionally tolerates the pre-3.0 bare ONLINE/OFFLINE text form. This
+/// type only ever produces the v3.0 JSON shape, matching
+/// 's refusal to produce the legacy topic.
+///
+///
+/// The two bodies share one timestamp, and that is a spec requirement rather than a
+/// convenience. Sparkplug 3.0 pairs a host's birth and death certificates by their
+/// timestamp field: an edge node that receives an OFFLINE whose timestamp is
+/// older than the ONLINE it already holds is entitled to ignore it as a stale death
+/// from a previous session. That rule is what makes the mechanism safe across a reconnect —
+/// and it means the death body has to be built before CONNECT (a Will is registered in
+/// the CONNECT packet) while the birth is published after CONNACK. Deriving each from
+/// its own UtcNow would give them different timestamps and quietly break the pairing,
+/// so mints both at once and there is no single-body overload to
+/// reach for by accident.
+///
+///
+public static class SparkplugStatePayload
+{
+ ///
+ /// QoS for both STATE bodies. Sparkplug 3.0 requires QoS 1 for the host birth and death —
+ /// unlike the QoS 0 that carries NBIRTH/DBIRTH/DATA. A host claim delivered at most once is a
+ /// claim that may simply never arrive.
+ ///
+ public const int StateQos = 1;
+
+ ///
+ /// Mints the matched ONLINE/OFFLINE pair for one host session, both carrying
+ /// .
+ ///
+ /// The Sparkplug host id; used to build the topic.
+ ///
+ /// Sparkplug epoch milliseconds for this session. Supplied by the caller rather than read from
+ /// the clock here so the value is visible at the call site and testable.
+ ///
+ /// The topic plus both bodies.
+ /// is blank.
+ public static SparkplugStateSession ForSession(string hostId, long timestampMs)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(hostId);
+
+ return new SparkplugStateSession(
+ Topic: SparkplugTopic.FormatState(hostId),
+ TimestampMs: timestampMs,
+ OnlineBody: Encode(online: true, timestampMs),
+ OfflineBody: Encode(online: false, timestampMs));
+ }
+
+ /// Encodes one STATE body: {"online":<bool>,"timestamp":<ms>}.
+ /// The online flag.
+ /// Sparkplug epoch milliseconds.
+ /// The UTF-8 body bytes.
+ ///
+ /// Hand-written rather than serialized from a DTO. The body is two fields, and the wire form
+ /// is fixed by the spec — a serializer would put naming policy, casing options and a shared
+ /// JsonSerializerOptions between this repo and a wire contract it does not control.
+ /// timestamp is emitted as a bare JSON number, never a string: a quoted timestamp
+ /// parses as a valid JSON object and is silently wrong to every conformant host.
+ ///
+ public static byte[] Encode(bool online, long timestampMs) =>
+ Encoding.UTF8.GetBytes(
+ $"{{\"online\":{(online ? "true" : "false")},\"timestamp\":{timestampMs.ToString(System.Globalization.CultureInfo.InvariantCulture)}}}");
+
+ /// Reads the online flag back out of a v3.0 STATE body, for tests and diagnostics.
+ /// The STATE body bytes.
+ /// The decoded flag on success.
+ /// The decoded timestamp on success.
+ /// when the body is a well-formed v3.0 STATE object.
+ public static bool TryDecode(ReadOnlySpan body, out bool online, out long timestampMs)
+ {
+ online = false;
+ timestampMs = 0;
+
+ if (body.IsEmpty)
+ {
+ return false;
+ }
+
+ try
+ {
+ var reader = new Utf8JsonReader(body);
+ if (!JsonDocument.TryParseValue(ref reader, out var doc))
+ {
+ return false;
+ }
+
+ using (doc)
+ {
+ if (doc.RootElement.ValueKind != JsonValueKind.Object
+ || !doc.RootElement.TryGetProperty("online", out var flag)
+ || flag.ValueKind is not (JsonValueKind.True or JsonValueKind.False))
+ {
+ return false;
+ }
+
+ online = flag.GetBoolean();
+ timestampMs = doc.RootElement.TryGetProperty("timestamp", out var ts)
+ && ts.ValueKind == JsonValueKind.Number
+ && ts.TryGetInt64(out var parsed)
+ ? parsed
+ : 0;
+ return true;
+ }
+ }
+ catch (JsonException)
+ {
+ return false;
+ }
+ }
+}
+
+///
+/// The STATE topic plus the matched pair of bodies for one primary-host session (Gitea #511).
+///
+/// The v3.0 STATE topic, spBv1.0/STATE/{hostId}.
+/// The session timestamp carried by BOTH bodies.
+///
+/// The birth certificate — published retained, at QoS 1, immediately after CONNACK.
+///
+///
+/// The death certificate — registered as the MQTT Last Will in the CONNECT packet, and also
+/// published explicitly on a clean stand-down so the broker holds it without waiting for a
+/// keep-alive timeout.
+///
+public sealed record SparkplugStateSession(
+ string Topic,
+ long TimestampMs,
+ byte[] OnlineBody,
+ byte[] OfflineBody);
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Transport/MqttClientOptionsFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Transport/MqttClientOptionsFactory.cs
index 4e4e9278..8b0dcd69 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Transport/MqttClientOptionsFactory.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Transport/MqttClientOptionsFactory.cs
@@ -41,20 +41,51 @@ public static class MqttClientOptionsFactory
/// empty MQTTnet generates a random client id.
///
/// Optional logger for the deferred CA-pin load; never receives credentials.
+ ///
+ /// Optional MQTT Last Will registered in the CONNECT packet (Gitea #511 — the Sparkplug
+ /// primary-host death certificate). See for why this can only be
+ /// supplied here.
+ ///
+ ///
+ /// Forces clean-session on or off, ignoring . Used
+ /// by the primary-host connection, which must connect clean so the broker cannot resume a
+ /// previous session and discard the Will — see .
+ ///
public static MqttClientOptions BuildClientOptions(
MqttDriverOptions options,
string? clientIdSuffix,
- ILogger? logger = null)
+ ILogger? logger = null,
+ MqttLastWill? lastWill = null,
+ bool? cleanSessionOverride = null)
{
ArgumentNullException.ThrowIfNull(options);
var builder = new MqttClientOptionsBuilder()
.WithTcpServer(options.Host, options.Port)
.WithProtocolVersion(MapProtocolVersion(options.ProtocolVersion))
- .WithCleanSession(options.CleanSession)
+ .WithCleanSession(cleanSessionOverride ?? options.CleanSession)
.WithKeepAlivePeriod(TimeSpan.FromSeconds(options.KeepAliveSeconds))
.WithTimeout(TimeSpan.FromSeconds(options.ConnectTimeoutSeconds));
+ if (lastWill is { } will)
+ {
+ builder = builder
+ .WithWillTopic(will.Topic)
+ .WithWillPayload(will.Payload)
+ .WithWillQualityOfServiceLevel((MQTTnet.Protocol.MqttQualityOfServiceLevel)will.Qos)
+ .WithWillRetain(will.Retain);
+
+ // MQTT 5.0 only. Left at its default the broker MAY hold the Will back for up to the
+ // session-expiry interval after an ungraceful disconnect, which for a host death
+ // certificate is precisely the wrong behaviour: the whole point is that edge nodes learn
+ // promptly. Zero means "publish it the moment the network connection is lost".
+ // The setter is harmless on 3.1.1 — the property simply has no wire representation there.
+ if (options.ProtocolVersion == MqttProtocolVersion.V500)
+ {
+ builder = builder.WithWillDelayInterval(0);
+ }
+ }
+
var clientId = string.Concat(options.ClientId ?? string.Empty, clientIdSuffix ?? string.Empty);
if (!string.IsNullOrWhiteSpace(clientId))
{
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs
index 6582de2e..57219b83 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs
@@ -286,11 +286,40 @@ public sealed class MqttConnection : IAsyncDisposable, IMqttSubscribeTransport,
private Task? _reconnectWorker;
private int _state = (int)MqttConnectionState.Disconnected;
+ /// Client-id suffix distinguishing this connection from the driver's other one; see the ctor.
+ private readonly string? _clientIdSuffix;
+
+ /// The Will to register on every CONNECT, or null. See .
+ private readonly MqttLastWill? _lastWill;
+
+ /// Clean-session override, or null to use the configured value.
+ private readonly bool? _cleanSessionOverride;
+
/// Stores configuration only — no network, no client instantiation, no supervisor.
/// Broker connection settings.
/// Driver instance id, used only for log/diagnostic correlation.
/// Optional logger; never receives credentials.
- public MqttConnection(MqttDriverOptions options, string driverId, ILogger? logger = null)
+ ///
+ /// Appended to the configured client id so a second connection from the same driver — the
+ /// Sparkplug primary-host claim (Gitea #511) — does not collide with the ingest session. Two
+ /// MQTT clients presenting one client id is not an error the broker reports: it silently
+ /// disconnects the older one, so the two would take turns evicting each other forever.
+ ///
+ ///
+ /// Optional Last Will registered in the CONNECT packet, and therefore rebuilt on every
+ /// reconnect attempt along with the rest of the options. See .
+ ///
+ ///
+ /// Forces clean-session on or off regardless of .
+ /// Null keeps the configured value.
+ ///
+ public MqttConnection(
+ MqttDriverOptions options,
+ string driverId,
+ ILogger? logger = null,
+ string? clientIdSuffix = null,
+ MqttLastWill? lastWill = null,
+ bool? cleanSessionOverride = null)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentException.ThrowIfNullOrWhiteSpace(driverId);
@@ -298,6 +327,9 @@ public sealed class MqttConnection : IAsyncDisposable, IMqttSubscribeTransport,
_options = options;
_driverId = driverId;
_logger = logger;
+ _clientIdSuffix = clientIdSuffix;
+ _lastWill = lastWill;
+ _cleanSessionOverride = cleanSessionOverride;
}
///
@@ -370,6 +402,47 @@ public sealed class MqttConnection : IAsyncDisposable, IMqttSubscribeTransport,
///
internal Func? AfterConnectHookForTests { get; set; }
+ ///
+ /// Test seam: drops the session the way a crashed process does — no MQTT DISCONNECT, so
+ /// the broker treats the closed socket as an abnormal termination and publishes the Last Will.
+ ///
+ /// A task completing once the client is gone and the supervisor has stopped.
+ ///
+ ///
+ /// Exists because the Sparkplug primary-host contract's most important leg is the one a
+ /// normal teardown cannot reach (Gitea #511): DisposeAsync sends a clean
+ /// DISCONNECT, which by MQTT rule means the broker discards the Will. So an
+ /// in-suite test driven through the ordinary lifecycle can only ever observe the explicit
+ /// stand-down OFFLINE, and would pass identically against an implementation whose
+ /// Will was never armed at all.
+ ///
+ ///
+ /// Not a full substitute for the kill -9 the live gate runs by hand — that also
+ /// proves the process dies without any of this code running — but it is the closest an
+ /// in-process test can get, and it is what makes the Will's content observable.
+ ///
+ ///
+ internal async Task AbandonForTests()
+ {
+ // Mark disposed first, so the supervisor cannot reconnect into the gap and publish a fresh
+ // birth on top of the Will the broker is about to deliver.
+ Interlocked.Exchange(ref _disposed, 1);
+ SetState(MqttConnectionState.Disposed);
+
+ try
+ {
+ await _lifetimeCts.CancelAsync().ConfigureAwait(false);
+ }
+ catch (ObjectDisposedException)
+ {
+ // Nothing to cancel.
+ }
+
+ // Dispose WITHOUT DisconnectAsync: MQTTnet closes the channel, the broker sees a dead socket
+ // rather than a graceful goodbye, and the Will fires. This is the whole point of the seam.
+ Interlocked.Exchange(ref _client, null)?.Dispose();
+ }
+
private bool Disposed => Volatile.Read(ref _disposed) != 0;
///
@@ -568,7 +641,8 @@ public sealed class MqttConnection : IAsyncDisposable, IMqttSubscribeTransport,
MqttClientOptions clientOptions;
try
{
- clientOptions = MqttClientOptionsFactory.BuildClientOptions(_options, clientIdSuffix: null, _logger);
+ clientOptions = MqttClientOptionsFactory.BuildClientOptions(
+ _options, _clientIdSuffix, _logger, _lastWill, _cleanSessionOverride);
}
catch (Exception ex)
{
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs
index 4f1e4d54..e8826724 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs
@@ -185,6 +185,16 @@ public sealed class MqttDriver
private MqttConnection? _connection;
private CancellationTokenSource? _hostProbeCts;
+ /// The Primary data-plane verdict the Sparkplug host claim is gated on (Gitea #511).
+ private readonly IDriverPrimaryRoleSource _primaryRoleSource;
+
+ ///
+ /// The Sparkplug primary-host claim, or when this configuration does not
+ /// ask for one. Owns its own broker connection — see for why
+ /// it cannot share the ingest session.
+ ///
+ private SparkplugPrimaryHost? _primaryHost;
+
private DriverHealth _health = new(DriverState.Unknown, null, null);
private HostState _hostState = HostState.Unknown;
private DateTime _hostStateChangedUtc = DateTime.UtcNow;
@@ -198,7 +208,18 @@ public sealed class MqttDriver
///
/// Stable logical id of this driver instance.
/// Optional logger; never receives credentials or payload bodies.
- public MqttDriver(MqttDriverOptions options, string driverInstanceId, ILogger? logger = null)
+ ///
+ /// Gitea #511: the Primary data-plane verdict the Sparkplug primary-host claim is gated on.
+ /// falls back to — which
+ /// always claims — for standalone hosts and tests. On a redundant pair that fallback is the
+ /// dual-claim this gate exists to prevent, so the Host resolves a real one and
+ /// DriverFactoryBootstrap does so with GetRequiredService.
+ ///
+ public MqttDriver(
+ MqttDriverOptions options,
+ string driverInstanceId,
+ ILogger? logger = null,
+ IDriverPrimaryRoleSource? primaryRoleSource = null)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
@@ -206,6 +227,7 @@ public sealed class MqttDriver
_options = options;
_driverInstanceId = driverInstanceId;
_logger = logger;
+ _primaryRoleSource = primaryRoleSource ?? UnmanagedDriverPrimaryRoleSource.Instance;
_subscriptions = CreateSubscriptionManager(options);
_sparkplug = CreateSparkplugIngestor(options);
RegisterAuthoredTags(options);
@@ -1001,6 +1023,7 @@ public sealed class MqttDriver
WriteHealth(new DriverHealth(DriverState.Healthy, connection.LastMessageUtc, null));
TransitionHost(HostState.Running);
StartHostProbe();
+ await StartPrimaryHostAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
@@ -1094,21 +1117,15 @@ public sealed class MqttDriver
return null;
}
- if (options.Sparkplug is { ActAsPrimaryHost: true })
+ // The flag no longer reads as if it does something it does not (Gitea #511) — but a request it
+ // CANNOT honour still warns loudly rather than being dropped, because the operator's plant is
+ // waiting on a STATE message that will never arrive.
+ if (SparkplugPrimaryHost.DescribeUnusable(options) is { } unusable)
{
- // Loud, because the flag reads as if it does something. Being a Sparkplug Primary Host is a
- // three-part contract — a retained ONLINE after CONNECT, a matching retained OFFLINE
- // registered as the MQTT Last Will AT CONNECT TIME, and an explicit OFFLINE on clean
- // shutdown — and edge nodes stop publishing while their host is offline. Shipping the
- // ONLINE half without the Will is strictly worse than shipping neither: a crashed node
- // would leave a retained ONLINE asserting forever that a dead host is alive, which is the
- // exact state the mechanism exists to prevent. The Will belongs in
- // MqttClientOptionsFactory.BuildClientOptions; until it is there this flag stays inert and said so.
_logger?.LogWarning(
- "MQTT driver '{DriverId}': Sparkplug actAsPrimaryHost is set but primary-host STATE "
- + "publishing is not implemented — this driver observes STATE and never publishes one. "
- + "Edge nodes gated on this host id will not see it come online.",
- _driverInstanceId);
+ "MQTT driver '{DriverId}': {Reason} Edge nodes gated on this host id will not see it come online.",
+ _driverInstanceId,
+ unusable);
}
var ingestor = new SparkplugIngestor(
@@ -1263,9 +1280,55 @@ public sealed class MqttDriver
o.Sparkplug,
};
+ ///
+ /// Opens the Sparkplug primary-host claim when this configuration asks for one (Gitea #511).
+ ///
+ ///
+ ///
+ /// Runs last, and can never fail the initialize. The claim is an advisory assertion
+ /// to other applications on the broker; the ingest path is this driver's data plane. A
+ /// broker that accepts the ingest session but rejects a publish on the STATE topic — an ACL
+ /// that does not grant spBv1.0/STATE/# is the ordinary way this happens — must cost
+ /// the operator a warning and a plant-visible missing host, not every tag's value.
+ /// already swallows and retries; the catch
+ /// here covers the rest so no future edit can turn this into a fault path by accident.
+ ///
+ ///
+ private async Task StartPrimaryHostAsync(CancellationToken cancellationToken)
+ {
+ if (!SparkplugPrimaryHost.CanClaim(_options))
+ {
+ return;
+ }
+
+ try
+ {
+ var host = new SparkplugPrimaryHost(_options, _driverInstanceId, _primaryRoleSource, _logger);
+ _primaryHost = host;
+ await host.StartAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger?.LogWarning(
+ ex,
+ "MQTT driver '{DriverId}': the Sparkplug primary-host claim could not be started; ingest is unaffected.",
+ _driverInstanceId);
+ }
+ }
+
/// Shared teardown for , and the session rebuild.
private async Task TeardownAsync()
{
+ // Stand the host claim down FIRST, while the broker is still reachable and before the ingest
+ // session goes. Its explicit retained OFFLINE is what makes the transition immediate for edge
+ // nodes; leaving it to the Will costs them up to 1.5 keep-alive periods of believing a host
+ // that has already stopped is still online.
+ var primaryHost = Interlocked.Exchange(ref _primaryHost, null);
+ if (primaryHost is not null)
+ {
+ await primaryHost.DisposeAsync().ConfigureAwait(false);
+ }
+
var probe = Interlocked.Exchange(ref _hostProbeCts, null);
if (probe is not null)
{
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverFactoryExtensions.cs
index dc6d9bea..cf6dc094 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverFactoryExtensions.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverFactoryExtensions.cs
@@ -49,16 +49,30 @@ public static class MqttDriverFactoryExtensions
///
/// The driver factory registry to register with.
/// Optional logger factory used to create per-instance loggers.
- public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
+ ///
+ /// Gitea #511: the Primary data-plane verdict the Sparkplug primary-host claim is gated on,
+ /// captured at registration time like . Null falls back to
+ /// (always claims) — correct for a standalone
+ /// host or a test, and the reason DriverFactoryBootstrap resolves it with
+ /// GetRequiredService rather than letting a redundant deployment reach that fallback.
+ ///
+ public static void Register(
+ DriverFactoryRegistry registry,
+ ILoggerFactory? loggerFactory = null,
+ IDriverPrimaryRoleSource? primaryRoleSource = null)
{
ArgumentNullException.ThrowIfNull(registry);
- registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory));
+ registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory, primaryRoleSource));
}
/// Public for the Server-side bootstrapper + test consumers.
/// Stable logical id of the driver instance.
/// The DriverConfig JSON blob for the instance.
/// Optional logger factory for the per-instance logger.
+ ///
+ /// Gitea #511: the Primary data-plane verdict gating the Sparkplug primary-host claim; null
+ /// falls back to .
+ ///
/// A configured, not-yet-connected .
///
/// or is blank.
@@ -68,7 +82,8 @@ public static class MqttDriverFactoryExtensions
public static MqttDriver CreateInstance(
string driverInstanceId,
string driverConfigJson,
- ILoggerFactory? loggerFactory = null)
+ ILoggerFactory? loggerFactory = null,
+ IDriverPrimaryRoleSource? primaryRoleSource = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
@@ -79,6 +94,7 @@ public static class MqttDriverFactoryExtensions
?? throw new InvalidOperationException(
$"MQTT driver config for '{driverInstanceId}' deserialised to null");
- return new MqttDriver(options, driverInstanceId, loggerFactory?.CreateLogger());
+ return new MqttDriver(
+ options, driverInstanceId, loggerFactory?.CreateLogger(), primaryRoleSource);
}
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/AliasTable.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/AliasTable.cs
index 45d66b0b..e5dc9030 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/AliasTable.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/AliasTable.cs
@@ -128,6 +128,36 @@ public sealed class AliasTable
aliasCollisions++;
(collidedAliases ??= []).Add(alias);
}
+
+ // Gitea #515: a Template/DataSet metric also installs one binding per flattened member,
+ // named `{parent}/{memberPath}`. The parent's own binding above is kept — a DATA metric
+ // carries only the parent's ALIAS, so resolving that alias to the parent's name is the only
+ // way the member names can be composed at all. But the parent is not itself servable (its
+ // Sparkplug datatype maps to no DriverDataType), so it is the member bindings that browse
+ // enumerates and that authored tags bind to.
+ //
+ // Members carry NO alias, deliberately: aliases are per-metric on the wire, so inventing
+ // one here would create a number the edge node never assigned, which a later birth could
+ // legitimately hand to a different metric.
+ foreach (var member in metric.MemberList)
+ {
+ if (string.IsNullOrWhiteSpace(member.Path))
+ {
+ rejectedUnnamed++;
+ continue;
+ }
+
+ var memberBinding = new SparkplugMetricBinding(
+ $"{binding.Name}{SparkplugFlattener.MemberSeparator}{member.Path}",
+ Alias: null,
+ member.DataType ?? TahuDataType.Unknown);
+
+ if (!byName.TryAdd(memberBinding.Name, memberBinding))
+ {
+ duplicateNames++;
+ byName[memberBinding.Name] = memberBinding;
+ }
+ }
}
if (collidedAliases is not null)
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SparkplugIngestor.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SparkplugIngestor.cs
index a5c294ef..156d356e 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SparkplugIngestor.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SparkplugIngestor.cs
@@ -73,9 +73,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
/// subscriber is contained, and the rebirth publish is dispatched off-thread.
///
///
-/// Primary-host STATE publishing is deliberately NOT implemented — see
-/// and the remarks on
-/// . This type observes STATE; it never publishes one.
+/// This type only ever observes STATE. Primary-host STATE publishing ships
+/// (Gitea #511) but deliberately does not live here: the claim is bound to the lifetime of its
+/// own dedicated connection, because an MQTT Will can only be registered at CONNECT. See
+/// , and the remarks on for what
+/// this side does with an observed one.
///
///
public sealed class SparkplugIngestor
@@ -1029,18 +1031,17 @@ public sealed class SparkplugIngestor
///
///
///
- /// Receive only. This driver never publishes STATE, and
- /// is therefore inert — deliberately, and
- /// loudly. Being a Sparkplug Primary Host is not "publish an ONLINE message": it is a
- /// three-part contract — a retained ONLINE STATE published after CONNECT, a matching
- /// retained OFFLINE registered as the client's MQTT Last Will at CONNECT time,
- /// and an explicit OFFLINE on clean shutdown. Edge nodes subscribe to it and stop publishing
- /// while their host is offline. Shipping the ONLINE half without the Will is strictly worse
- /// than shipping neither: an OtOpcUa node that crashes would leave a retained ONLINE asserting
- /// forever that a dead host is alive, which is precisely the state the mechanism exists to
- /// prevent. The Will has to be set inside MqttClientOptionsFactory.BuildClientOptions — a
- /// different file, a different task — so this task implements the observation half and
- /// warns when the flag is set. See the design doc §7/§8.
+ /// Receive only, on this connection. The publish half ships as
+ /// (Gitea #511), on a connection of its own — an MQTT Will
+ /// can only be registered in the CONNECT packet, so a claim that is acquired and released at
+ /// runtime cannot ride the long-lived ingest session this type is attached to.
+ ///
+ ///
+ /// A host claimed by THIS node is observed here too, and that is correct rather than an
+ /// oversight. The ingestor subscribes the configured host id whether or not this node
+ /// publishes it, so a node acting as primary host sees its own retained ONLINE come
+ /// back. Nothing here gates on it — is informational — so the
+ /// echo costs one log line at the transition and cannot feed back into the claim.
///
///
/// Both payload forms are accepted on receive: the v3.0 JSON {"online":bool,...} and
@@ -1176,6 +1177,15 @@ public sealed class SparkplugIngestor
SparkplugMetric metric,
DateTime? sourceUtc)
{
+ // Gitea #515: a Template/DataSet has no value of its own — its flattened members do. Expand
+ // here, at the single funnel both the DATA and the birth-value paths already pass through, so
+ // neither can end up supporting structured metrics while the other silently does not.
+ if (metric.ValueKind == SparkplugValueKind.Structured)
+ {
+ PublishStructuredMembers(scope, binding, metric, sourceUtc);
+ return;
+ }
+
// Bound by NAME. The alias got us here; it is never the key.
var key = new SparkplugMetricKey(scope.GroupId, scope.EdgeNodeId, scope.DeviceId, binding.Name);
if (!_authored.ByMetric.TryGetValue(key, out var defs))
@@ -1286,6 +1296,62 @@ public sealed class SparkplugIngestor
: BadTypeMismatch(def, binding, target, stamp, serverUtc);
}
+ ///
+ /// Publishes each flattened member of a structured (Template / DataSet) metric as
+ /// the sibling metric {parent}/{memberPath} (Gitea #515).
+ ///
+ ///
+ ///
+ /// Each member is re-entered through as a scalar, rather
+ /// than duplicating the coercion, array-gate and authored-tag fan-out here. A second copy of
+ /// that logic would be free to drift — and the drift would be silent, since both paths
+ /// publish values that look plausible.
+ ///
+ ///
+ /// The member's datatype falls back to the BIRTH's. A DDATA template usually
+ /// re-declares its members' datatypes, but it is not obliged to; the binding installed at
+ /// birth is the authority when it does not. Guessing from the CLR type of the wire value
+ /// would type a signed Int32 of -42 as a UInt32 of 4294967254 — the exact
+ /// plausible-and-wrong failure ReinterpretSigned exists to prevent.
+ ///
+ ///
+ private void PublishStructuredMembers(
+ SparkplugScope scope,
+ SparkplugMetricBinding parent,
+ SparkplugMetric metric,
+ DateTime? sourceUtc)
+ {
+ var table = Births.Find(scope);
+
+ foreach (var member in metric.MemberList)
+ {
+ if (string.IsNullOrWhiteSpace(member.Path))
+ {
+ continue;
+ }
+
+ var name = $"{parent.Name}{SparkplugFlattener.MemberSeparator}{member.Path}";
+
+ // Wire datatype first, birth binding second. Unknown last — which the typed layer refuses
+ // once, loudly, rather than coercing.
+ var dataType = member.DataType
+ ?? table?.ResolveByName(name)?.DataType
+ ?? Org.Eclipse.Tahu.Protobuf.DataType.Unknown;
+
+ PublishMetric(
+ scope,
+ new SparkplugMetricBinding(name, Alias: null, dataType),
+ new SparkplugMetric(
+ Name: name,
+ Alias: null,
+ DataType: dataType,
+ TimestampMs: metric.TimestampMs,
+ ValueKind: member.ValueKind,
+ Value: member.Value),
+ sourceUtc);
+ }
+ }
+
///
/// The metric decoded, but does not fit the tag's effective type. Warned once per tag; the value
/// itself is deliberately never logged — a payload value is plant data, not diagnostics.
@@ -1369,9 +1435,7 @@ public sealed class SparkplugIngestor
try
{
- subscribers(this, new SparkplugBirthObservedEventArgs(
- scope,
- [.. payload.Metrics.Where(m => m.Name is not null).Select(m => m.Name!)]));
+ subscribers(this, new SparkplugBirthObservedEventArgs(scope, [.. DiscoverableNames(payload)]));
}
catch (Exception ex)
{
@@ -1379,6 +1443,38 @@ public sealed class SparkplugIngestor
}
}
+ ///
+ /// The metric names a birth contributes to the discoverable address space — which is
+ /// what the rediscovery change gate must compare, not the raw wire names.
+ ///
+ ///
+ /// Gitea #515: a structured metric contributes its flattened MEMBERS and not itself. Comparing
+ /// raw wire names instead would make the gate blind to the change that matters most for a UDT:
+ /// a template gaining or losing a member leaves the parent metric's name identical, so the
+ /// signature would not move and the operator would never be told the tree changed.
+ ///
+ private static IEnumerable DiscoverableNames(SparkplugPayload payload)
+ {
+ foreach (var metric in payload.Metrics)
+ {
+ if (metric.Name is not { } name)
+ {
+ continue;
+ }
+
+ if (metric.ValueKind != SparkplugValueKind.Structured)
+ {
+ yield return name;
+ continue;
+ }
+
+ foreach (var member in metric.MemberList)
+ {
+ yield return $"{name}{SparkplugFlattener.MemberSeparator}{member.Path}";
+ }
+ }
+ }
+
// ---- sequence + rebirth ----
private SequenceTracker TrackerFor(SparkplugNodeKey node) =>
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SparkplugPrimaryHost.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SparkplugPrimaryHost.cs
new file mode 100644
index 00000000..7911c872
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SparkplugPrimaryHost.cs
@@ -0,0 +1,470 @@
+using Microsoft.Extensions.Logging;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
+
+///
+/// Claims the Sparkplug Primary Host Application identity for this node — the three-part contract
+/// promises and, until Gitea #511, did not
+/// deliver: a retained ONLINE while the claim is held, a matching retained OFFLINE
+/// registered as the MQTT Last Will, and an explicit OFFLINE on a clean stand-down.
+///
+///
+///
+/// The claim is a whole dedicated connection, not a message. A Will can only be
+/// registered in the CONNECT packet (see ), so a claim that must be
+/// acquired and released at runtime cannot live on the driver's shared ingest session — that
+/// session connects once and stays up, and its Will could be neither added on gaining the role
+/// nor withdrawn on losing it. This type therefore opens its own connection when the
+/// node gains Primary and closes it when the node loses Primary. The connection's lifetime
+/// is the claim, which is what makes every leg of the contract fall out for free:
+///
+/// the Will is registered because the connection was opened to hold the claim;
+/// a kill -9 drops the connection, so the broker publishes the Will;
+/// a clean stand-down publishes OFFLINE and then disconnects, so the retained
+/// message is correct before the Will could ever fire.
+///
+///
+///
+/// Why not rebuild the ingest connection instead. That was the shape first sketched on
+/// the issue, and it works, but it drops every subscription and the whole birth cache on each
+/// role change — producing a data gap on the Secondary→Primary transition, which is precisely
+/// the moment a plant most needs values flowing. A second connection costs one extra client id
+/// and leaves ingest untouched.
+///
+///
+/// Primary-gated, by owner decision (2026-07-31). Both nodes of a redundant pair
+/// subscribe independently — MQTT fan-out is per-connection and the Primary gate applies
+/// downstream of ingest — so without gating, both would claim the same host id. The sharp
+/// failure is not the two fighting over the retained STATE: it is that the
+/// Secondary's death would publish OFFLINE, edge nodes would stop publishing,
+/// and the surviving Primary would be perfectly healthy throughout. Redundancy would cause the
+/// outage the mechanism exists to prevent. The gate is
+/// , sampled on a timer.
+///
+///
+/// Accepted trade: a brief edge-visible OFFLINE window during failover, while the
+/// old Primary stands down and the new one connects and republishes. That is a genuine state
+/// transition — for a window there really is no host — not a glitch to paper over.
+///
+///
+/// Reconnect re-asserts the claim. The dedicated connection carries the same supervisor
+/// as the ingest one, and Reconnected republishes the birth. Without that, a broker
+/// restart would leave the host connected but its retained ONLINE gone — a live host
+/// invisible to every edge node, with nothing in any log to say so.
+///
+///
+public sealed class SparkplugPrimaryHost : IAsyncDisposable
+{
+ ///
+ /// Appended to the driver's configured client id for the STATE connection. Two MQTT clients
+ /// presenting one client id is not reported as an error — the broker silently evicts the older
+ /// one — so ingest and the host claim would take turns killing each other forever.
+ ///
+ internal const string ClientIdSuffix = "-spbhost";
+
+ /// How often the role gate is sampled. Bounds the failover republish latency.
+ private static readonly TimeSpan RoleSampleInterval = TimeSpan.FromSeconds(1);
+
+ private readonly MqttDriverOptions _options;
+ private readonly string _hostId;
+ private readonly string _driverId;
+ private readonly IDriverPrimaryRoleSource _roleSource;
+ private readonly ILogger? _logger;
+ private readonly TimeProvider _timeProvider;
+
+ /// Serializes claim/release against each other and against .
+ private readonly SemaphoreSlim _gate = new(1, 1);
+
+ private CancellationTokenSource? _loopCts;
+
+ /// The live claim, or null when this node is not currently the primary host.
+ private MqttConnection? _connection;
+
+ /// The session bodies backing ; non-null exactly when it is.
+ private SparkplugStateSession? _session;
+
+ private int _disposed;
+
+ /// Creates a primary-host claim. Opens nothing — does that.
+ /// Broker connection settings; the host id comes from its Sparkplug section.
+ /// Driver instance id, for log correlation.
+ /// The Primary data-plane verdict this claim is gated on.
+ /// Optional logger.
+ /// Clock seam; defaults to .
+ ///
+ /// The options carry no Sparkplug host id — exists so a caller never has
+ /// to hit this.
+ ///
+ public SparkplugPrimaryHost(
+ MqttDriverOptions options,
+ string driverId,
+ IDriverPrimaryRoleSource roleSource,
+ ILogger? logger = null,
+ TimeProvider? timeProvider = null)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ ArgumentException.ThrowIfNullOrWhiteSpace(driverId);
+ ArgumentNullException.ThrowIfNull(roleSource);
+
+ var hostId = options.Sparkplug?.HostId;
+ if (string.IsNullOrWhiteSpace(hostId))
+ {
+ throw new ArgumentException(
+ "A Sparkplug primary-host claim needs a hostId; check CanClaim first.", nameof(options));
+ }
+
+ _options = options;
+ _hostId = hostId.Trim();
+ _driverId = driverId;
+ _roleSource = roleSource;
+ _logger = logger;
+ _timeProvider = timeProvider ?? TimeProvider.System;
+ }
+
+ ///
+ /// Whether asks for — and can support — a primary-host claim:
+ /// Sparkplug mode, actAsPrimaryHost set, and a non-blank hostId.
+ ///
+ /// The driver options to test.
+ /// when a claim should be made.
+ ///
+ /// The blank-host-id case is a configuration error the AdminUI form already refuses, but the
+ /// driver must not fault on it: the config could have been authored before that validation
+ /// existed. It is reported by and the flag is treated as off.
+ ///
+ public static bool CanClaim(MqttDriverOptions options) =>
+ options is { Mode: MqttMode.SparkplugB, Sparkplug: { ActAsPrimaryHost: true } spb }
+ && !string.IsNullOrWhiteSpace(spb.HostId);
+
+ ///
+ /// Describes why an actAsPrimaryHost request cannot be honoured, or null when it can be
+ /// (or was never made).
+ ///
+ /// The driver options to test.
+ /// An operator-facing reason, or .
+ public static string? DescribeUnusable(MqttDriverOptions options)
+ {
+ if (options?.Sparkplug is not { ActAsPrimaryHost: true } spb)
+ {
+ return null;
+ }
+
+ if (options.Mode != MqttMode.SparkplugB)
+ {
+ return "actAsPrimaryHost is set but the driver is not in SparkplugB mode; there is no host state to publish.";
+ }
+
+ return string.IsNullOrWhiteSpace(spb.HostId)
+ ? "actAsPrimaryHost is set but hostId is blank; a host claim has no topic to publish on."
+ : null;
+ }
+
+ /// The STATE topic this claim publishes on.
+ public string StateTopic => SparkplugTopic.FormatState(_hostId);
+
+ ///
+ /// Whether the claim is currently held — i.e. a retained ONLINE has been published and a
+ /// Will is armed behind it.
+ ///
+ public bool IsClaimed => Volatile.Read(ref _connection) is not null;
+
+ ///
+ /// Test seam: how many times a claim connect has been attempted, successful or not.
+ /// Always ignored in production.
+ ///
+ ///
+ /// Without this, "a Secondary does not claim" is a vacuous assertion. The obvious test —
+ /// start a Secondary and assert is false — passes identically whether
+ /// the gate works or whether the gate is deleted and the connect simply fails against a broker
+ /// the test cannot reach. Counting attempts separates "did not try" from "tried and failed",
+ /// which is the whole distinction the gate is.
+ ///
+ internal int ClaimAttemptsForTests => Volatile.Read(ref _claimAttempts);
+
+ private int _claimAttempts;
+
+ ///
+ /// Test seam: kills the claim connection the way a crashed process does — no clean
+ /// DISCONNECT and no explicit stand-down publish — so the broker delivers the Last Will.
+ /// See for why this cannot be reached through the
+ /// ordinary lifecycle.
+ ///
+ /// A task completing once the connection is gone.
+ internal async Task AbandonClaimForTests()
+ {
+ // Stop the reconcile loop first, or the next tick sees no connection, decides the claim is
+ // lost, and re-claims — republishing ONLINE over the Will this is meant to observe.
+ var loop = Interlocked.Exchange(ref _loopCts, null);
+ if (loop is not null)
+ {
+ await loop.CancelAsync().ConfigureAwait(false);
+ loop.Dispose();
+ }
+
+ var connection = Interlocked.Exchange(ref _connection, null);
+ _session = null;
+ if (connection is not null)
+ {
+ await connection.AbandonForTests().ConfigureAwait(false);
+ }
+ }
+
+ ///
+ /// Starts the role-reconcile loop. Returns once the first reconcile has run, so a caller that
+ /// starts as Primary has published its ONLINE before this completes.
+ ///
+ /// Cancels the initial reconcile.
+ /// A task completing after the first reconcile.
+ public async Task StartAsync(CancellationToken cancellationToken)
+ {
+ ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
+
+ var cts = new CancellationTokenSource();
+ var previous = Interlocked.Exchange(ref _loopCts, cts);
+ if (previous is not null)
+ {
+ await previous.CancelAsync().ConfigureAwait(false);
+ previous.Dispose();
+ }
+
+ // Reconcile once inline. A failure here is logged and swallowed, never rethrown: the driver's
+ // ingest path is the data plane, and refusing to start it because a broker would not accept a
+ // host claim would trade every tag's value for an advisory message. The loop retries.
+ await ReconcileAsync(cancellationToken).ConfigureAwait(false);
+
+ _ = Task.Run(() => ReconcileLoopAsync(cts.Token), CancellationToken.None);
+ }
+
+ ///
+ /// Stands the claim down cleanly: publishes an explicit retained OFFLINE, then
+ /// disconnects. Idempotent, and safe to call when no claim is held.
+ ///
+ /// Bounds the stand-down publish.
+ /// A task completing when the claim is released.
+ public async Task StopAsync(CancellationToken cancellationToken)
+ {
+ var loop = Interlocked.Exchange(ref _loopCts, null);
+ if (loop is not null)
+ {
+ await loop.CancelAsync().ConfigureAwait(false);
+ loop.Dispose();
+ }
+
+ await ReleaseAsync("shutdown", cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ if (Interlocked.Exchange(ref _disposed, 1) != 0)
+ {
+ return;
+ }
+
+ // Bounded: dispose must not hang on an unreachable broker. A stand-down we cannot publish is
+ // covered by the Will the moment the socket closes, which is the whole point of having one.
+ using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(_options.ConnectTimeoutSeconds));
+ try
+ {
+ await StopAsync(deadline.Token).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger?.LogDebug(
+ ex, "MQTT driver '{DriverId}': Sparkplug primary-host stand-down failed during dispose.", _driverId);
+ }
+
+ _gate.Dispose();
+ }
+
+ private async Task ReconcileLoopAsync(CancellationToken ct)
+ {
+ try
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ await Task.Delay(RoleSampleInterval, _timeProvider, ct).ConfigureAwait(false);
+ await ReconcileAsync(ct).ConfigureAwait(false);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Shutdown.
+ }
+ catch (Exception ex)
+ {
+ // The loop must not die silently — a dead loop means the claim freezes in whatever state
+ // it was last in, including "claimed by a node that is no longer Primary".
+ _logger?.LogError(
+ ex, "MQTT driver '{DriverId}': Sparkplug primary-host reconcile loop stopped.", _driverId);
+ }
+ }
+
+ ///
+ /// Brings the held claim into line with the current role verdict. Never throws — a transient
+ /// broker failure leaves the claim as it was and the next tick tries again.
+ ///
+ private async Task ReconcileAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ if (_roleSource.ShouldActAsPrimary)
+ {
+ await ClaimAsync(cancellationToken).ConfigureAwait(false);
+ }
+ else
+ {
+ await ReleaseAsync("this node is not the redundancy Primary", cancellationToken).ConfigureAwait(false);
+ }
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logger?.LogWarning(
+ ex,
+ "MQTT driver '{DriverId}': could not reconcile the Sparkplug primary-host claim for '{HostId}'; will retry.",
+ _driverId,
+ _hostId);
+ }
+ }
+
+ private async Task ClaimAsync(CancellationToken cancellationToken)
+ {
+ if (Volatile.Read(ref _connection) is not null)
+ {
+ return; // Already held; the connection's own supervisor keeps it that way.
+ }
+
+ await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ if (_connection is not null || Volatile.Read(ref _disposed) != 0)
+ {
+ return;
+ }
+
+ Interlocked.Increment(ref _claimAttempts);
+
+ // ONE timestamp for both bodies — the Will is built here, before CONNECT, and the birth
+ // published after CONNACK. See SparkplugStatePayload's remarks on why they must match.
+ var session = SparkplugStatePayload.ForSession(
+ _hostId, _timeProvider.GetUtcNow().ToUnixTimeMilliseconds());
+
+ var connection = new MqttConnection(
+ _options,
+ _driverId,
+ _logger,
+ clientIdSuffix: ClientIdSuffix,
+ lastWill: new MqttLastWill(
+ session.Topic, session.OfflineBody, SparkplugStatePayload.StateQos, Retain: true),
+ // Forced clean, ignoring the driver's configured value: a broker that resumes a prior
+ // session for this client id may keep the OLD session's Will and discard the one this
+ // CONNECT carries — leaving a claim with no death certificate behind it, which is the
+ // single state this whole mechanism exists to make impossible.
+ cleanSessionOverride: true);
+
+ // Re-assert on every reconnect. A broker restart drops retained messages; without this the
+ // host would be connected and believed offline by every edge node, silently.
+ connection.Reconnected += async ct =>
+ {
+ await connection.PublishAsync(
+ session.Topic, session.OnlineBody, SparkplugStatePayload.StateQos, retain: true, ct)
+ .ConfigureAwait(false);
+
+ _logger?.LogInformation(
+ "MQTT driver '{DriverId}': re-published Sparkplug primary-host ONLINE for '{HostId}' after reconnect.",
+ _driverId,
+ _hostId);
+ };
+
+ await connection.ConnectAsync(cancellationToken).ConfigureAwait(false);
+
+ try
+ {
+ await connection.PublishAsync(
+ session.Topic, session.OnlineBody, SparkplugStatePayload.StateQos, retain: true, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ catch
+ {
+ // A connection whose birth never landed is worse than no connection: it holds a Will
+ // that will publish OFFLINE for a claim that was never asserted, and it looks claimed
+ // to IsClaimed. Tear it down and let the next tick retry from scratch.
+ await connection.DisposeAsync().ConfigureAwait(false);
+ throw;
+ }
+
+ _session = session;
+ Volatile.Write(ref _connection, connection);
+
+ _logger?.LogInformation(
+ "MQTT driver '{DriverId}': claimed Sparkplug primary host '{HostId}' — retained ONLINE published on '{Topic}', "
+ + "OFFLINE armed as the Last Will.",
+ _driverId,
+ _hostId,
+ session.Topic);
+ }
+ finally
+ {
+ _gate.Release();
+ }
+ }
+
+ private async Task ReleaseAsync(string reason, CancellationToken cancellationToken)
+ {
+ if (Volatile.Read(ref _connection) is null)
+ {
+ return;
+ }
+
+ await _gate.WaitAsync(CancellationToken.None).ConfigureAwait(false);
+ try
+ {
+ var connection = Interlocked.Exchange(ref _connection, null);
+ var session = _session;
+ _session = null;
+ if (connection is null || session is null)
+ {
+ return;
+ }
+
+ try
+ {
+ // Explicit OFFLINE before disconnecting. The Will would eventually say the same thing,
+ // but only after the broker's keep-alive timeout notices the socket is gone — up to
+ // 1.5 keep-alive periods of edge nodes believing a host that has already stood down is
+ // still online. Publishing it makes the transition immediate.
+ await connection.PublishAsync(
+ session.Topic, session.OfflineBody, SparkplugStatePayload.StateQos, retain: true, cancellationToken)
+ .ConfigureAwait(false);
+
+ _logger?.LogInformation(
+ "MQTT driver '{DriverId}': released Sparkplug primary host '{HostId}' ({Reason}) — retained OFFLINE published.",
+ _driverId,
+ _hostId,
+ reason);
+ }
+ catch (Exception ex)
+ {
+ // Not fatal, and not silent. The Will still covers this: dropping the connection below
+ // makes the broker publish the identical OFFLINE body. The only thing lost is promptness.
+ _logger?.LogWarning(
+ ex,
+ "MQTT driver '{DriverId}': could not publish the Sparkplug primary-host OFFLINE for '{HostId}'; "
+ + "the Last Will will deliver it when the session drops.",
+ _driverId,
+ _hostId);
+ }
+
+ await connection.DisposeAsync().ConfigureAwait(false);
+ }
+ finally
+ {
+ _gate.Release();
+ }
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.csproj
index 79edc634..2a88db4e 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.csproj
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.csproj
@@ -25,6 +25,10 @@
+
+
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/MqttDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/MqttDriverForm.razor
index cb18f6bc..bef3d0d3 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/MqttDriverForm.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/MqttDriverForm.razor
@@ -225,7 +225,7 @@
-
Sparkplug Host Application identity. Receive-only in this version.
+
Sparkplug Host Application identity — the spBv1.0/STATE/{hostId} topic. Required to act as Primary Host.
@@ -237,25 +237,42 @@
@if (_form.SparkplugActAsPrimaryHost)
{
- @* Shown rather than the flag being hidden: an operator who needs a primary host must
- learn it is absent HERE, not from a plant that never sees a STATE message. The
- driver logs the same warning at startup so the two cannot drift. *@
-
-
- Primary-host STATE publishing is not implemented. This driver is
- receive-only: it will not publish spBv1.0/STATE/@(string.IsNullOrWhiteSpace(_form.SparkplugHostId) ? "{HostId}" : _form.SparkplugHostId),
- so edge nodes will not treat it as their primary host. The driver logs a warning at
- startup rather than being silently inert. Leave this off unless you are staging
- configuration ahead of that feature.
+ @* Two distinct messages, and which one shows is decided by SparkplugPrimaryHostAdvice —
+ a plain static, not markup logic, because this repo has no bUnit and a Razor-only rule
+ is untestable. A blank host id is a REFUSAL (there is no topic to publish on) and must
+ not read like the ordinary case; the driver logs the same distinction at startup so the
+ two surfaces cannot drift. *@
+ @if (SparkplugPrimaryHostAdvice.DescribeBlockingProblem(_form.SparkplugHostId) is { } problem)
+ {
+
+
+ This host claim cannot be made. @problem
+
-
+ }
+ else
+ {
+
+
+ This node will publish a retained ONLINE on
+ spBv1.0/STATE/@_form.SparkplugHostId.Trim() and arm a matching
+ OFFLINE as its MQTT Last Will, so edge nodes learn promptly
+ whether the host is alive — including after a crash.
+ Only the node holding the redundancy Primary role claims it.
+ Two nodes claiming one host id would let a Secondary's death publish
+ OFFLINE over a healthy Primary and stop the whole group, so a
+ failover leaves a brief window in which edge nodes correctly see no host.
+ At most one primary host may exist per host id per broker — do not set this on two
+ separate deployments pointing at the same broker.
+
+
+ }
}
@@ -263,7 +280,6 @@
Sparkplug tags bind by the group / edge node / device / metric
tuple, not by topic — author them with Browse device… on a device under
this driver, which builds its tree from observed birth certificates.
- MQTT tags are read-only (write-through is not implemented).
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/MqttDriverFormModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/MqttDriverFormModel.cs
index a003bc63..ebc3c40f 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/MqttDriverFormModel.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/MqttDriverFormModel.cs
@@ -142,17 +142,23 @@ public sealed class MqttDriverFormModel
public string SparkplugGroupId { get; set; } = "";
///
- /// Sparkplug mode: the Host Application identity. Optional, and receive-only in this
- /// version — see .
+ /// Sparkplug mode: the Host Application identity — the spBv1.0/STATE/{hostId} topic the
+ /// driver observes, and publishes on when is set.
+ /// Optional for observation; required to claim.
///
public string SparkplugHostId { get; set; } = "";
///
- /// Sparkplug mode: claim the Primary Host Application role. Not implemented — STATE
- /// publishing is out of scope, and the driver logs a warning rather than being silently inert
- /// when this is set. Surfaced (rather than hidden) so an operator who needs it learns that from
- /// the form instead of from a quiet plant.
+ /// Sparkplug mode: claim the Primary Host Application role (Gitea #511) — a retained
+ /// ONLINE, a matching OFFLINE armed as the MQTT Last Will, and an explicit
+ /// OFFLINE on clean shutdown.
///
+ ///
+ /// Primary-gated: only the node holding the redundancy Primary role claims it, so the two
+ /// nodes of a pair cannot fight over one host id. At most one primary host may exist per host id
+ /// per broker, which the form states — it is not enforceable from here, since a second
+ /// deployment pointing at the same broker is invisible to this one.
+ ///
public bool SparkplugActAsPrimaryHost { get; set; }
/// Sparkplug mode: answer a detected sequence-number gap with a rebirth request (NCMD).
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/SparkplugPrimaryHostAdvice.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/SparkplugPrimaryHostAdvice.cs
new file mode 100644
index 00000000..1601d9aa
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/SparkplugPrimaryHostAdvice.cs
@@ -0,0 +1,35 @@
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
+
+///
+/// The rule deciding which primary-host advisory MqttDriverForm shows when
+/// Act as Primary Host is on (Gitea #511).
+///
+///
+///
+/// A plain static rather than markup logic, because this repo has no bUnit. A condition
+/// written inline in a .razor file is unreachable from any test, and the failure mode
+/// here is specific: an operator who ticks the box with a blank host id must see a
+/// refusal, not the ordinary "here is what will happen" note. Getting that backwards
+/// tells them a claim is being made when the driver is in fact declining to make one.
+///
+///
+/// Deliberately mirrors SparkplugPrimaryHost.DescribeUnusable's blank-host-id arm. The
+/// mode arm is not duplicated: this panel only renders inside the SparkplugB branch of the
+/// form, so mode cannot be wrong here.
+///
+///
+public static class SparkplugPrimaryHostAdvice
+{
+ ///
+ /// Describes why a primary-host claim cannot be made for , or
+ /// when it can.
+ ///
+ /// The Sparkplug host id as typed into the form.
+ /// An operator-facing reason, or .
+ public static string? DescribeBlockingProblem(string? hostId) =>
+ string.IsNullOrWhiteSpace(hostId)
+ ? "A Sparkplug host claim is published on spBv1.0/STATE/{hostId}, so it needs a Host ID. "
+ + "Set one above, or turn this off — the driver will otherwise log a warning at startup "
+ + "and publish nothing."
+ : null;
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs
index 3b743031..4943dee6 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs
@@ -5,6 +5,7 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
using ZB.MOM.WW.Secrets.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Runtime.Redundancy;
namespace ZB.MOM.WW.OtOpcUa.Host.Drivers;
@@ -45,6 +46,19 @@ public static class DriverFactoryBootstrap
/// The same service collection, for chaining.
public static IServiceCollection AddOtOpcUaDriverFactories(this IServiceCollection services)
{
+ // Gitea #511: the Primary data-plane verdict the MQTT driver's Sparkplug host claim is gated on.
+ //
+ // Registered HERE as well as in AddOtOpcUaRuntime, and both sides TryAdd the same concrete
+ // RedundancyRoleView, so whichever extension runs first creates it and the other resolves the
+ // SAME instance. That is deliberately not a defensive duplicate: the resolve below is
+ // GetRequiredService (a null would silently fall the driver back to "claim unconditionally",
+ // which on a redundant pair is the dual-claim this mechanism exists to prevent), so without
+ // this line the registration becomes order-dependent on two extensions a caller is free to
+ // invoke in either order — and any graph that wires driver factories without the full runtime
+ // fails at resolution. Two Host integration suites do exactly that, and caught it.
+ services.TryAddSingleton();
+ services.TryAddSingleton(sp => sp.GetRequiredService());
+
services.AddSingleton(sp =>
{
var registry = new DriverFactoryRegistry();
@@ -55,7 +69,16 @@ public static class DriverFactoryBootstrap
// The Galaxy driver resolves its Gateway.ApiKeySecretRef secret: arm through the
// shared ISecretResolver (registered unconditionally by AddZbSecrets on every node).
var secretResolver = sp.GetRequiredService();
- Register(registry, loggerFactory, secretResolver, scriptRoot);
+ // Gitea #511: the MQTT driver's Sparkplug primary-host claim is Primary-gated, and unlike
+ // every other gated operation it is a STANDING assertion on the driver's own connection —
+ // there is no inbound message for DriverHostActor to intercept — so the verdict has to
+ // reach the driver itself. Registered unconditionally on driver-role nodes by
+ // AddOtOpcUaRuntime. GetRequiredService, not GetService: a null here silently falls the
+ // driver back to "claim unconditionally", which on a redundant pair is the dual-claim this
+ // whole mechanism exists to prevent, and it would fail as a plant outage rather than as a
+ // startup error.
+ var primaryRoleSource = sp.GetRequiredService();
+ Register(registry, loggerFactory, secretResolver, primaryRoleSource, scriptRoot);
return registry;
});
services.AddSingleton(sp =>
@@ -142,6 +165,7 @@ public static class DriverFactoryBootstrap
DriverFactoryRegistry registry,
ILoggerFactory? loggerFactory,
ISecretResolver secretResolver,
+ IDriverPrimaryRoleSource primaryRoleSource,
ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger? scriptRoot = null)
{
Driver.AbCip.AbCipDriverFactoryExtensions.Register(registry);
@@ -150,7 +174,7 @@ public static class DriverFactoryBootstrap
Driver.FOCAS.FocasDriverFactoryExtensions.Register(registry);
Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry, secretResolver, loggerFactory);
Driver.Modbus.ModbusDriverFactoryExtensions.Register(registry, loggerFactory);
- Driver.Mqtt.MqttDriverFactoryExtensions.Register(registry, loggerFactory);
+ Driver.Mqtt.MqttDriverFactoryExtensions.Register(registry, loggerFactory, primaryRoleSource);
// Tier A (the Register default): fully managed — HttpClient + System.Xml.Linq, no native SDK,
// no COM. Tier C is the only tier that arms process-level recycle (MemoryRecycle hard-breach /
// ScheduledRecycleScheduler), which would be wrong here: the driver is in-process and killing
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
index 5359cfa5..6d257dbd 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
@@ -273,6 +273,19 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// today, the alarm store-and-forward drain. Null on nodes that do not wire one.
private readonly IRedundancyRoleView? _redundancyRoleView;
+ ///
+ /// Where the Primary data-plane verdict is published for drivers that must know it
+ /// directly (Gitea #511). Null on nodes that wire no redundancy manager.
+ ///
+ ///
+ /// Note the different verdict from : that one carries
+ /// , which OPENS on an unknown role. A
+ /// Sparkplug host-id claim is an exclusive assertion on a shared broker, so it takes the
+ /// data-plane verdict, which closes. Publishing the drain verdict here would let both nodes of
+ /// a pair claim the host id during every boot window.
+ ///
+ private readonly IDriverPrimaryRoleSource? _primaryRoleSource;
+
///
/// Host of this node's LocalDb replication partner — the only node that holds a copy of this
/// node's alarm queue, and therefore the only node it may ever stand down in favour of.
@@ -408,7 +421,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
bool fetchAndCacheMode = false,
IDeploymentArtifactFetcher? artifactFetcher = null,
IAlarmStateStore? alarmStateStore = null,
- ITelemetryLocalHub? telemetryHub = null) =>
+ ITelemetryLocalHub? telemetryHub = null,
+ IDriverPrimaryRoleSource? primaryRoleSource = null) =>
// WARNING: this forwarding list is POSITIONAL, and Props.Create compiles it into an
// expression tree. Six IActorRef? parameters and several interface-typed ones mean a
// mis-ordered argument is usually type-compatible and therefore compiles clean, then binds
@@ -419,7 +433,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
healthPublisher, virtualTagEvaluator, historyWriter, virtualTagHostOverride,
loggerFactory, scriptRootLogger, scriptedAlarmHostOverride, invokerFactory, driverMemberCountProvider,
deploymentArtifactCache, redundancyRoleView, replicationPeerHost, fetchAndCacheMode, artifactFetcher,
- alarmStateStore, telemetryHub));
+ alarmStateStore, telemetryHub, primaryRoleSource));
/// Initializes a new DriverHostActor with the specified dependencies.
/// Database context factory for configuration database access.
@@ -460,6 +474,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// Per-cluster mesh Phase 5: optional node-local live-telemetry hub each
/// Primary-gated native-alarm transition is also emitted into, and which is threaded into the spawned
/// VirtualTag + ScriptedAlarm hosts. Null (admin-only nodes / tests) skips the emit and passes null down.
+ /// Gitea #511: where the Primary data-plane verdict is published
+ /// for drivers that assert a standing exclusive identity on a shared external system — today the MQTT
+ /// driver's Sparkplug primary-host STATE claim. Deliberately a separate parameter from
+ /// even though the production object implements both: a test that
+ /// supplies only a role-view double must not silently also satisfy this, or its primary-role assertions
+ /// pass without the publish ever happening. Null on nodes that wire no redundancy manager.
public DriverHostActor(
IDbContextFactory? dbFactory,
CommonsNodeId localNode,
@@ -483,11 +503,13 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
bool fetchAndCacheMode = false,
IDeploymentArtifactFetcher? artifactFetcher = null,
IAlarmStateStore? alarmStateStore = null,
- ITelemetryLocalHub? telemetryHub = null)
+ ITelemetryLocalHub? telemetryHub = null,
+ IDriverPrimaryRoleSource? primaryRoleSource = null)
{
_telemetryHub = telemetryHub;
_deploymentArtifactCache = deploymentArtifactCache;
_redundancyRoleView = redundancyRoleView;
+ _primaryRoleSource = primaryRoleSource;
_replicationPeerHost = replicationPeerHost;
_fetchAndCacheMode = fetchAndCacheMode;
_artifactFetcher = artifactFetcher;
@@ -1331,6 +1353,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
_redundancyRoleView?.Publish(
PrimaryGatePolicy.ShouldDrainAlarmHistory(_localRole, queueSharingPeerIsPrimary: peerIsPrimary));
+
+ // The DATA-PLANE verdict, deliberately — the same one HandleRouteNodeWrite gates on, not the
+ // drain verdict published immediately above. A driver reading this claims an exclusive identity
+ // on a shared external system (Gitea #511: the Sparkplug host id), where two claimants is the
+ // harm, so an unknown role with a driver peer present must deny.
+ _primaryRoleSource?.PublishPrimaryRole(ShouldServiceAsPrimary());
}
private void Stale()
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Redundancy/IRedundancyRoleView.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Redundancy/IRedundancyRoleView.cs
index a9c41dff..b450e9e2 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Redundancy/IRedundancyRoleView.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Redundancy/IRedundancyRoleView.cs
@@ -1,3 +1,4 @@
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Redundancy;
@@ -39,8 +40,22 @@ public interface IRedundancyRoleView
void Publish(bool shouldDrainAlarmHistory);
}
-/// Thread-safe backed by a volatile field.
-public sealed class RedundancyRoleView : IRedundancyRoleView
+///
+/// Thread-safe + , each
+/// backed by its own volatile field.
+///
+///
+///
+/// Two verdicts, one object, on purpose. Both are published from the same
+/// DriverHostActor snapshot handler and both are read off-actor, so a second class
+/// would duplicate the publish plumbing to hold a second bool. They are kept as distinct
+/// properties rather than collapsed into one because they are genuinely different
+/// decisions from different policies — see 's remarks on why
+/// the drain gate must open on an unknown role where the data-plane gate closes. Collapsing
+/// them is the bug that live gate already found once.
+///
+///
+public sealed class RedundancyRoleView : IRedundancyRoleView, IDriverPrimaryRoleSource
{
// Seeded with the decision for "role unknown" rather than a bare false. An unpublished view and a
// node whose role nothing ever reports are genuinely the same situation, and they must behave the
@@ -49,9 +64,22 @@ public sealed class RedundancyRoleView : IRedundancyRoleView
private volatile bool _shouldDrainAlarmHistory =
PrimaryGatePolicy.ShouldDrainAlarmHistory(localRole: null, queueSharingPeerIsPrimary: false);
+ // Seeded from the same policy that will later publish here, for the unknown-role/no-peer case:
+ // a node nothing has reported a role for, on a cluster with no driver peer, is a single-node
+ // deployment and may claim. The moment a snapshot arrives this is replaced by the real verdict,
+ // and a multi-driver cluster's unknown role denies.
+ private volatile bool _shouldActAsPrimary =
+ PrimaryGatePolicy.ShouldServiceAsPrimary(localRole: null, driverMemberCount: 1);
+
///
public bool ShouldDrainAlarmHistory => _shouldDrainAlarmHistory;
+ ///
+ public bool ShouldActAsPrimary => _shouldActAsPrimary;
+
///
public void Publish(bool shouldDrainAlarmHistory) => _shouldDrainAlarmHistory = shouldDrainAlarmHistory;
+
+ ///
+ public void PublishPrimaryRole(bool shouldActAsPrimary) => _shouldActAsPrimary = shouldActAsPrimary;
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs
index 12648380..793088f0 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs
@@ -75,6 +75,22 @@ public static class ServiceCollectionExtensions
// telemetry (never DPS) to the Phase-5 gRPC streaming service central dials in on. AddOtOpcUaRuntime
// runs only inside Program.cs's hasDriver block, so this lands on driver-role nodes only.
services.TryAddSingleton();
+
+ // Gitea #511: the Primary data-plane verdict, published for drivers that assert a standing
+ // exclusive identity on a shared external system (today the MQTT driver's Sparkplug host id).
+ //
+ // Registered HERE, unconditionally on every driver-role node, and NOT alongside the alarm
+ // drain's IRedundancyRoleView — which AddAlarmHistorian registers only when that section is
+ // enabled. Piggybacking on it would leave a redundant pair with no alarm historian configured
+ // with no primary-role source at all, and the driver's documented no-source fallback is
+ // "claim" — so both nodes of that pair would claim the same host id and their Wills would
+ // fight over the retained STATE. Exactly the failure #511 exists to prevent, reached by a
+ // registration accident in an unrelated section.
+ //
+ // Both interfaces are forwarded to ONE RedundancyRoleView instance, so a node that has both
+ // publishes two verdicts into the same object rather than into two that disagree.
+ services.TryAddSingleton();
+ services.TryAddSingleton(sp => sp.GetRequiredService());
return services;
}
@@ -117,8 +133,10 @@ public static class ServiceCollectionExtensions
// The view is a plain singleton so it exists even on nodes whose DriverHostActor never
// publishes to it — an unpublished view reads as "no peer, drain", which is the correct
- // posture for a deployment that runs no redundancy at all.
- services.TryAddSingleton();
+ // posture for a deployment that runs no redundancy at all. Forwarded to the same instance
+ // AddOtOpcUaRuntime registered for IDriverPrimaryRoleSource: one object, two verdicts.
+ services.TryAddSingleton();
+ services.TryAddSingleton(sp => sp.GetRequiredService());
// THE GATE ONLY APPLIES TO A REPLICATED QUEUE. Standing down is only ever safe because some
// other node holds the same rows and will send them instead — and the only node that does is
@@ -499,7 +517,11 @@ public static class ServiceCollectionExtensions
// Phase 5: the node-local live-telemetry hub (registered in AddOtOpcUaRuntime). The host
// emits its Primary-gated native-alarm transitions into it and threads it into the
// VirtualTag + ScriptedAlarm hosts it spawns.
- telemetryHub: resolver.GetService()),
+ telemetryHub: resolver.GetService(),
+ // Gitea #511: where this actor publishes its Primary DATA-PLANE verdict for drivers
+ // that claim an exclusive external identity. Registered unconditionally by
+ // AddOtOpcUaRuntime, unlike redundancyRoleView above — see the comment there.
+ primaryRoleSource: resolver.GetService()),
DriverHostActorName);
registry.Register(driverHost);
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/MqttPrimaryHostLiveTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/MqttPrimaryHostLiveTests.cs
new file mode 100644
index 00000000..fc9f6825
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/MqttPrimaryHostLiveTests.cs
@@ -0,0 +1,379 @@
+using System.Buffers;
+using System.Collections.Concurrent;
+using MQTTnet;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests;
+
+///
+/// Live gate for the Sparkplug primary-host claim (Gitea #511), against the Mosquitto fixture.
+///
+///
+///
+/// The issue's acceptance criteria, leg by leg: the broker holds a retained ONLINE while
+/// the claim runs; an abrupt death leaves OFFLINE (the Will fires); a clean shutdown
+/// leaves OFFLINE; and the pair-node question is answered rather than left to chance —
+/// a node that is not Primary claims nothing, and a role change moves the claim.
+///
+///
+/// Each test uses its own host id. A Sparkplug host id is a broker-wide singleton by
+/// definition, and the retained STATE outlives the test that wrote it — two tests sharing an id
+/// would see each other's leftovers, and would do so intermittently depending on ordering.
+///
+///
+/// Retained messages are cleaned up. A retained ONLINE for a host that no longer
+/// exists is exactly the lie this feature prevents, so leaving one on a shared fixture would be
+/// poor manners at best and a confusing false positive for the next run at worst.
+///
+///
+[Trait("Category", "Integration")]
+[Collection("MqttFixture")]
+public sealed class MqttPrimaryHostLiveTests(MqttFixture fixture)
+{
+ private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(15);
+
+ private void SkipIfUnconfigured() => Assert.SkipWhen(fixture.SkipReason is not null, fixture.SkipReason ?? "");
+
+ [Fact]
+ public async Task A_primary_publishes_a_retained_ONLINE_that_a_late_subscriber_still_sees()
+ {
+ // Retention is the point, not an optimisation: an edge node that connects AFTER the host came
+ // up must still learn the host is alive. A non-retained ONLINE reaches only whoever happened to
+ // be subscribed at that instant, and every later joiner sees an absent host forever.
+ SkipIfUnconfigured();
+
+ var hostId = NewHostId();
+ var ct = TestContext.Current.CancellationToken;
+ await using var host = NewHost(hostId, isPrimary: true);
+
+ await host.StartAsync(ct);
+ host.IsClaimed.ShouldBeTrue("the fixture broker accepted the claim connection");
+
+ // Subscribe only NOW — after the birth was published — so only a retained message can satisfy it.
+ await using var watcher = await StateWatcher.StartAsync(fixture, hostId, ct);
+ var body = await watcher.WaitForOneAsync(Timeout, ct);
+
+ SparkplugStatePayload.TryDecode(body, out var online, out var timestamp).ShouldBeTrue();
+ online.ShouldBeTrue();
+ timestamp.ShouldBeGreaterThan(0);
+
+ await host.StopAsync(ct);
+ await ClearRetainedAsync(hostId, ct);
+ }
+
+ [Fact]
+ public async Task An_abrupt_death_leaves_a_retained_OFFLINE_because_the_will_fires()
+ {
+ // THE leg the whole design exists for, and the one no unit test can reach: proof that the
+ // broker is holding our death certificate and delivers it when the socket dies without a
+ // goodbye. Without it a crashed node would leave a retained ONLINE asserting forever that a
+ // dead host is alive — strictly worse than never publishing at all.
+ SkipIfUnconfigured();
+
+ var hostId = NewHostId();
+ var ct = TestContext.Current.CancellationToken;
+ var host = NewHost(hostId, isPrimary: true);
+
+ await host.StartAsync(ct);
+ host.IsClaimed.ShouldBeTrue();
+
+ await using var watcher = await StateWatcher.StartAsync(fixture, hostId, ct);
+ var birth = await watcher.WaitForOneAsync(Timeout, ct);
+ SparkplugStatePayload.TryDecode(birth, out var wasOnline, out var birthTimestamp).ShouldBeTrue();
+ wasOnline.ShouldBeTrue();
+
+ // Kill the socket with no DISCONNECT. A clean teardown would make the broker DISCARD the Will
+ // by protocol rule, so this test would then be observing the explicit stand-down publish and
+ // would pass against an implementation that never armed a Will.
+ await host.AbandonClaimForTests();
+
+ var death = await watcher.WaitForMatchAsync(
+ p => SparkplugStatePayload.TryDecode(p, out var o, out _) && !o, Timeout, ct);
+
+ SparkplugStatePayload.TryDecode(death, out _, out var deathTimestamp).ShouldBeTrue();
+
+ // Sparkplug 3.0 pairs a host's birth and death by timestamp — an edge node may ignore an
+ // OFFLINE older than the ONLINE it holds. Mismatched timestamps produce two well-formed
+ // messages and a death certificate conformant hosts are entitled to throw away.
+ deathTimestamp.ShouldBe(birthTimestamp);
+
+ await host.DisposeAsync();
+ await ClearRetainedAsync(hostId, ct);
+ }
+
+ [Fact]
+ public async Task A_clean_shutdown_leaves_a_retained_OFFLINE_without_waiting_for_the_will()
+ {
+ SkipIfUnconfigured();
+
+ var hostId = NewHostId();
+ var ct = TestContext.Current.CancellationToken;
+ var host = NewHost(hostId, isPrimary: true);
+
+ await host.StartAsync(ct);
+ await using var watcher = await StateWatcher.StartAsync(fixture, hostId, ct);
+ await watcher.WaitForMatchAsync(
+ p => SparkplugStatePayload.TryDecode(p, out var o, out _) && o, Timeout, ct);
+
+ await host.StopAsync(ct);
+
+ // Promptly — the assertion is that this arrives well inside the keep-alive window it would take
+ // the broker to notice a dead socket, which is why the explicit publish exists at all.
+ await watcher.WaitForMatchAsync(
+ p => SparkplugStatePayload.TryDecode(p, out var o, out _) && !o, Timeout, ct);
+
+ await host.DisposeAsync();
+ await ClearRetainedAsync(hostId, ct);
+ }
+
+ [Fact]
+ public async Task A_node_that_is_not_primary_publishes_nothing_at_all()
+ {
+ // The pair-node answer. Not merely "does not win the race" — a Secondary must never open a
+ // claim connection, because the Will it would register is the hazard: its own death would then
+ // publish OFFLINE over a perfectly healthy Primary's ONLINE and stop the whole group.
+ SkipIfUnconfigured();
+
+ var hostId = NewHostId();
+ var ct = TestContext.Current.CancellationToken;
+ await using var watcher = await StateWatcher.StartAsync(fixture, hostId, ct);
+ await using var host = NewHost(hostId, isPrimary: false);
+
+ await host.StartAsync(ct);
+ host.ClaimAttemptsForTests.ShouldBe(0);
+
+ // Give the reconcile loop several ticks to misbehave in.
+ await Task.Delay(TimeSpan.FromSeconds(3), ct);
+
+ host.IsClaimed.ShouldBeFalse();
+ host.ClaimAttemptsForTests.ShouldBe(0);
+ watcher.Count.ShouldBe(0, "a Secondary must not touch the STATE topic");
+ }
+
+ [Fact]
+ public async Task A_failover_moves_the_claim_offline_then_online_again()
+ {
+ // Both halves of the accepted trade, observed: losing Primary publishes OFFLINE (a genuine
+ // state transition — for a window there really is no host), and regaining it republishes
+ // ONLINE. The republish is the half that would be easy to omit; without it a node that failed
+ // back would run as Primary while every edge node believed it was gone.
+ SkipIfUnconfigured();
+
+ var hostId = NewHostId();
+ var ct = TestContext.Current.CancellationToken;
+ var role = new MutableRole(isPrimary: true);
+ await using var host = new SparkplugPrimaryHost(Options(hostId), "live-failover", role);
+
+ await using var watcher = await StateWatcher.StartAsync(fixture, hostId, ct);
+
+ await host.StartAsync(ct);
+ await watcher.WaitForMatchAsync(
+ p => SparkplugStatePayload.TryDecode(p, out var o, out _) && o, Timeout, ct);
+
+ role.IsPrimary = false;
+ await watcher.WaitForMatchAsync(
+ p => SparkplugStatePayload.TryDecode(p, out var o, out _) && !o, Timeout, ct);
+ await WaitUntilAsync(() => !host.IsClaimed, ct);
+
+ var offlineSeen = watcher.CountMatching(
+ p => SparkplugStatePayload.TryDecode(p, out var o, out _) && !o);
+
+ role.IsPrimary = true;
+ await WaitUntilAsync(() => host.IsClaimed, ct);
+
+ // A SECOND online, after the offline — not merely "an online exists", which the birth already
+ // satisfied before the failover even started.
+ await WaitUntilAsync(
+ () => watcher.CountMatching(p => SparkplugStatePayload.TryDecode(p, out var o, out _) && o) >= 2,
+ ct);
+
+ offlineSeen.ShouldBeGreaterThanOrEqualTo(1);
+
+ await host.StopAsync(ct);
+ await ClearRetainedAsync(hostId, ct);
+ }
+
+ // ---- helpers -----------------------------------------------------------------------------------
+
+ /// A per-test host id: the retained STATE outlives the test that wrote it.
+ private static string NewHostId() => $"otopcua-live-{Guid.NewGuid():N}"[..28];
+
+ private MqttDriverOptions Options(string hostId) => new()
+ {
+ Host = fixture.PlainHost,
+ Port = fixture.PlainPort,
+ Username = fixture.Username,
+ Password = fixture.Password,
+ UseTls = false,
+ ClientId = $"otopcua-live-{Guid.NewGuid():N}"[..20],
+ ConnectTimeoutSeconds = 10,
+ Mode = MqttMode.SparkplugB,
+ Sparkplug = new MqttSparkplugOptions
+ {
+ GroupId = "OtOpcUaSim",
+ HostId = hostId,
+ ActAsPrimaryHost = true,
+ },
+ };
+
+ private SparkplugPrimaryHost NewHost(string hostId, bool isPrimary) =>
+ new(Options(hostId), $"live-{hostId}", new MutableRole(isPrimary));
+
+ ///
+ /// Clears the retained STATE this test left behind — a zero-length retained publish is MQTT's
+ /// "forget this". Leaving a retained ONLINE for a host that no longer exists is precisely the
+ /// lie the feature prevents.
+ ///
+ private async Task ClearRetainedAsync(string hostId, CancellationToken ct)
+ {
+ var client = new MqttClientFactory().CreateMqttClient();
+ try
+ {
+ await client.ConnectAsync(
+ new MqttClientOptionsBuilder()
+ .WithTcpServer(fixture.PlainHost, fixture.PlainPort)
+ .WithCredentials(fixture.Username, fixture.Password)
+ .WithClientId($"otopcua-live-clean-{Guid.NewGuid():N}"[..23])
+ .WithTimeout(TimeSpan.FromSeconds(10))
+ .Build(),
+ ct);
+
+ await client.PublishAsync(
+ new MqttApplicationMessageBuilder()
+ .WithTopic(SparkplugTopic.FormatState(hostId))
+ .WithPayload(Array.Empty())
+ .WithRetainFlag(true)
+ .Build(),
+ ct);
+
+ await client.DisconnectAsync(cancellationToken: ct);
+ }
+ finally
+ {
+ client.Dispose();
+ }
+ }
+
+ private static async Task WaitUntilAsync(Func condition, CancellationToken ct)
+ {
+ var deadline = DateTime.UtcNow + Timeout;
+ while (!condition())
+ {
+ if (DateTime.UtcNow >= deadline)
+ {
+ throw new TimeoutException($"Condition was not satisfied within {Timeout}.");
+ }
+
+ await Task.Delay(100, ct);
+ }
+ }
+
+ /// A role source a test can flip, standing in for a redundancy failover.
+ private sealed class MutableRole(bool isPrimary) : IDriverPrimaryRoleSource
+ {
+ private volatile bool _isPrimary = isPrimary;
+
+ public bool IsPrimary
+ {
+ get => _isPrimary;
+ set => _isPrimary = value;
+ }
+
+ public bool ShouldActAsPrimary => _isPrimary;
+
+ public void PublishPrimaryRole(bool shouldActAsPrimary) => _isPrimary = shouldActAsPrimary;
+ }
+
+ /// Subscribes one host's STATE topic and records every body seen, in order.
+ private sealed class StateWatcher : IAsyncDisposable
+ {
+ private readonly IMqttClient _client;
+ private readonly ConcurrentQueue _bodies = new();
+
+ private StateWatcher(IMqttClient client) => _client = client;
+
+ public int Count => _bodies.Count;
+
+ public static async Task StartAsync(MqttFixture fixture, string hostId, CancellationToken ct)
+ {
+ var client = new MqttClientFactory().CreateMqttClient();
+ var watcher = new StateWatcher(client);
+
+ client.ApplicationMessageReceivedAsync += args =>
+ {
+ watcher._bodies.Enqueue(args.ApplicationMessage.Payload.ToArray());
+ return Task.CompletedTask;
+ };
+
+ await client.ConnectAsync(
+ new MqttClientOptionsBuilder()
+ .WithTcpServer(fixture.PlainHost, fixture.PlainPort)
+ .WithCredentials(fixture.Username, fixture.Password)
+ .WithClientId($"otopcua-live-state-{Guid.NewGuid():N}"[..23])
+ .WithTimeout(TimeSpan.FromSeconds(10))
+ .Build(),
+ ct);
+
+ await client.SubscribeAsync(
+ new MqttClientSubscribeOptionsBuilder()
+ .WithTopicFilter(f => f.WithTopic(SparkplugTopic.FormatState(hostId)))
+ .Build(),
+ ct);
+
+ return watcher;
+ }
+
+ public int CountMatching(Func predicate) => _bodies.Count(b => Safe(predicate, b));
+
+ public async Task WaitForOneAsync(TimeSpan timeout, CancellationToken ct)
+ => await WaitForMatchAsync(_ => true, timeout, ct);
+
+ public async Task WaitForMatchAsync(Func predicate, TimeSpan timeout, CancellationToken ct)
+ {
+ var deadline = DateTime.UtcNow + timeout;
+ while (true)
+ {
+ if (_bodies.FirstOrDefault(b => Safe(predicate, b)) is { } match)
+ {
+ return match;
+ }
+
+ if (DateTime.UtcNow >= deadline)
+ {
+ throw new TimeoutException(
+ $"No matching STATE message within {timeout} ({_bodies.Count} seen, none matching).");
+ }
+
+ await Task.Delay(100, ct);
+ }
+ }
+
+ private static bool Safe(Func predicate, byte[] body)
+ {
+ try { return predicate(body); }
+ catch { return false; }
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ try
+ {
+ if (_client.IsConnected)
+ {
+ await _client.DisconnectAsync();
+ }
+ }
+ catch
+ {
+ // Teardown only.
+ }
+ finally
+ {
+ _client.Dispose();
+ }
+ }
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/SparkplugSimulator/Program.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/SparkplugSimulator/Program.cs
index db045291..87b81759 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/SparkplugSimulator/Program.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/SparkplugSimulator/Program.cs
@@ -81,7 +81,14 @@ foreach (var node in nodes)
"Filler1",
new SparkplugMetricSeed("Temperature", 10, DataType.Float, 55.5f),
new SparkplugMetricSeed("FillCount", 11, DataType.Int64, 1000L),
- new SparkplugMetricSeed("Jammed", 12, DataType.Boolean, false));
+ new SparkplugMetricSeed("Jammed", 12, DataType.Boolean, false),
+
+ // Structured metrics for the Gitea #515 gate. They live on the DEVICE rather than the node
+ // deliberately: a DDATA template is the case a naive implementation gets wrong, because its
+ // metric carries the parent's ALIAS and no name, so the member names cannot be composed until
+ // the alias table has resolved the parent.
+ new SparkplugMetricSeed("Motor", 13, DataType.Template, BuildMotorTemplate()),
+ new SparkplugMetricSeed("Recipe", 14, DataType.DataSet, BuildRecipeDataSet()));
edges.Add(edge);
}
@@ -112,3 +119,55 @@ return 0;
static string Env(string name, string fallback) =>
Environment.GetEnvironmentVariable(name) is { Length: > 0 } value ? value : fallback;
+
+// A UDT instance: `Motor` with a `Speed` double, a `Running` bool and a nested `Stator` template.
+// Flattens to `Motor/Speed`, `Motor/Running` and `Motor/Stator/Temp` (Gitea #515).
+static Payload.Types.Template BuildMotorTemplate()
+{
+ var stator = new Payload.Types.Template { Version = "1", IsDefinition = false };
+ stator.Metrics.Add(new Payload.Types.Metric
+ {
+ Name = "Temp", Datatype = (uint)DataType.Double, DoubleValue = 71.5,
+ });
+
+ var motor = new Payload.Types.Template { Version = "1", IsDefinition = false };
+ motor.Metrics.Add(new Payload.Types.Metric
+ {
+ Name = "Speed", Datatype = (uint)DataType.Double, DoubleValue = 1500,
+ });
+ motor.Metrics.Add(new Payload.Types.Metric
+ {
+ Name = "Running", Datatype = (uint)DataType.Boolean, BooleanValue = true,
+ });
+
+ // Nested one level: proves the recursion, and proves the `/`-joined path composes correctly rather
+ // than only working for flat templates.
+ motor.Metrics.Add(new Payload.Types.Metric
+ {
+ Name = "Stator", Datatype = (uint)DataType.Template, TemplateValue = stator,
+ });
+
+ return motor;
+}
+
+// A two-column, two-row table. Flattens to `Recipe/Step/0`, `Recipe/Target/0`, `Recipe/Step/1`,
+// `Recipe/Target/1` — the row index is always present, so a table that grows a row never renames the
+// tags already authored against it.
+static Payload.Types.DataSet BuildRecipeDataSet()
+{
+ var dataSet = new Payload.Types.DataSet { NumOfColumns = 2UL };
+ dataSet.Columns.Add("Step");
+ dataSet.Columns.Add("Target");
+ dataSet.Types_.Add((uint)DataType.Int32);
+ dataSet.Types_.Add((uint)DataType.Double);
+
+ for (var row = 0; row < 2; row++)
+ {
+ var r = new Payload.Types.DataSet.Types.Row();
+ r.Elements.Add(new Payload.Types.DataSet.Types.DataSetValue { IntValue = (uint)row });
+ r.Elements.Add(new Payload.Types.DataSet.Types.DataSetValue { DoubleValue = 20d + row });
+ dataSet.Rows.Add(r);
+ }
+
+ return dataSet;
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/SparkplugSimulator/SparkplugEdgeNode.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/SparkplugSimulator/SparkplugEdgeNode.cs
index 0140af0d..425cafdc 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/SparkplugSimulator/SparkplugEdgeNode.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/SparkplugSimulator/SparkplugEdgeNode.cs
@@ -756,6 +756,18 @@ public sealed class SparkplugEdgeNode : IAsyncDisposable
case DataType.File:
metric.BytesValue = ByteString.CopyFrom((byte[])value);
break;
+
+ // Structured values (Gitea #515). The seed carries the already-built protobuf message
+ // rather than a scalar, because a Template's shape IS its value — there is nothing simpler
+ // to convert from, and inventing a mini-DSL here would put a second, divergent notion of
+ // "what a template is" between the simulator and the wire it is supposed to reproduce.
+ case DataType.Template:
+ metric.TemplateValue = (Payload.Types.Template)value;
+ break;
+ case DataType.DataSet:
+ metric.DatasetValue = (Payload.Types.DataSet)value;
+ break;
+
default:
throw new NotSupportedException(
$"The simulator does not encode Sparkplug datatype {dataType}; v1 of the driver does "
@@ -824,9 +836,83 @@ public sealed class SparkplugEdgeNode : IAsyncDisposable
DataType.Uint8 or DataType.Uint16 or DataType.Uint32 or DataType.Uint64 => (uint)tick,
DataType.Boolean => tick % 2 == 0,
DataType.String or DataType.Text or DataType.Uuid => $"tick-{tick}",
+
+ // Structured values move too, or the live gate could not tell a flattened member that is being
+ // fed from one that was merely published once at birth and then went quiet.
+ DataType.Template => AdvanceTemplate((Payload.Types.Template)seed.Value!, tick),
+ DataType.DataSet => AdvanceDataSet((Payload.Types.DataSet)seed.Value!, tick),
+
_ => seed.Value,
};
+ ///
+ /// Rebuilds a template with its numeric members nudged, preserving names, datatypes and order.
+ ///
+ private static Payload.Types.Template AdvanceTemplate(Payload.Types.Template seed, int tick)
+ {
+ var next = new Payload.Types.Template { Version = seed.Version, IsDefinition = false };
+ foreach (var member in seed.Metrics)
+ {
+ var clone = member.Clone();
+ switch ((DataType)clone.Datatype)
+ {
+ case DataType.Double:
+ clone.DoubleValue = 1500d + (tick % 100);
+ break;
+ case DataType.Float:
+ clone.FloatValue = 40f + (tick % 20) / 10f;
+ break;
+ case DataType.Int32:
+ clone.IntValue = unchecked((uint)(-tick));
+ break;
+ case DataType.Boolean:
+ clone.BooleanValue = tick % 2 == 0;
+ break;
+ default:
+ break; // leave anything else at its seeded value
+ }
+
+ clone.Timestamp = NowMs();
+ next.Metrics.Add(clone);
+ }
+
+ return next;
+ }
+
+ /// Rebuilds a dataset with its numeric cells nudged, preserving columns, types and shape.
+ private static Payload.Types.DataSet AdvanceDataSet(Payload.Types.DataSet seed, int tick)
+ {
+ var next = new Payload.Types.DataSet { NumOfColumns = seed.NumOfColumns };
+ next.Columns.AddRange(seed.Columns);
+ next.Types_.AddRange(seed.Types_);
+
+ for (var row = 0; row < seed.Rows.Count; row++)
+ {
+ var cloned = new Payload.Types.DataSet.Types.Row();
+ for (var col = 0; col < seed.Rows[row].Elements.Count; col++)
+ {
+ var cell = seed.Rows[row].Elements[col].Clone();
+ switch ((DataType)seed.Types_[col])
+ {
+ case DataType.Double:
+ cell.DoubleValue = 20d + row + (tick % 10) / 10d;
+ break;
+ case DataType.Int32:
+ cell.IntValue = (uint)(tick + row);
+ break;
+ default:
+ break;
+ }
+
+ cloned.Elements.Add(cell);
+ }
+
+ next.Rows.Add(cloned);
+ }
+
+ return next;
+ }
+
private void ConfigureTls(MqttClientTlsOptionsBuilder tls)
{
if (!_options.UseTls)
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/SparkplugStructuredLiveTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/SparkplugStructuredLiveTests.cs
new file mode 100644
index 00000000..1ccb00c5
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/SparkplugStructuredLiveTests.cs
@@ -0,0 +1,332 @@
+using Org.Eclipse.Tahu.Protobuf;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.SparkplugSimulator;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests;
+
+///
+/// Live gate for structured Sparkplug metrics (Gitea #515): a Template and a
+/// DataSet published by a real edge node over the Mosquitto fixture, flattened into
+/// addressable OPC UA tags.
+///
+///
+///
+/// What this adds over the offline suite. The offline tests build decoded payloads by
+/// hand, so they prove the mapping but not the wire: that a nested template really does
+/// round-trip through protobuf with its member names intact, and — the case a naive
+/// implementation gets wrong — that a DDATA structured metric arrives carrying the parent's
+/// alias and nothing else, so the member names cannot be composed until the alias table
+/// has resolved the parent.
+///
+///
+/// The issue's acceptance criterion, verbatim: "a simulator publishing a Template and a
+/// DataSet metric produces addressable, correctly-typed OPC UA nodes, live-verified;
+/// every still-unsupported kind continues to skip with a warning."
+///
+///
+[Trait("Category", "Integration")]
+[Collection("MqttFixture")]
+public sealed class SparkplugStructuredLiveTests(MqttFixture fixture)
+{
+ private const uint Good = 0x00000000u;
+ private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(20);
+
+ private void SkipIfUnconfigured() => Assert.SkipWhen(fixture.SkipReason is not null, fixture.SkipReason ?? "");
+
+ [Fact]
+ public async Task A_template_metric_flattens_to_addressable_tags_across_birth_and_data()
+ {
+ SkipIfUnconfigured();
+
+ var ct = TestContext.Current.CancellationToken;
+ var group = NewGroup("tmpl");
+ const string node = "EdgeA";
+ const string device = "Filler1";
+
+ const string speedPath = "Fixture/Sparkplug/Motor/Speed";
+ const string runningPath = "Fixture/Sparkplug/Motor/Running";
+ const string statorPath = "Fixture/Sparkplug/Motor/Stator/Temp";
+
+ await using var sim = new SparkplugEdgeNode(SimOptions(group, node));
+ sim.SetDeviceMetrics(device, new SparkplugMetricSeed("Motor", 13, DataType.Template, MotorTemplate(speed: 1500, running: true, statorTemp: 71.5)));
+
+ await sim.ConnectAsync(ct);
+ await sim.BirthAsync(ct);
+
+ await using var driver = new MqttDriver(
+ SparkplugOptions(
+ group,
+ Tag(speedPath, group, node, device, "Motor/Speed"),
+ Tag(runningPath, group, node, device, "Motor/Running"),
+ Tag(statorPath, group, node, device, "Motor/Stator/Temp")),
+ "mqtt-live-sp-template");
+ var recorder = new Recorder(driver);
+
+ await driver.InitializeAsync(string.Empty, ct);
+ await driver.SubscribeAsync([speedPath, runningPath, statorPath], TimeSpan.FromSeconds(1), ct);
+
+ // The DBIRTH is a full snapshot, so these are the birth's own member values — including the
+ // NESTED template's, which is what proves the `/`-joined path composes past one level.
+ var birth = await recorder.WaitAsync(speedPath, v => Equals(v.Value, 1500d), Timeout, ct);
+ birth.Snapshot.StatusCode.ShouldBe(Good);
+ await recorder.WaitAsync(runningPath, v => Equals(v.Value, true), Timeout, ct);
+ await recorder.WaitAsync(statorPath, v => Equals(v.Value, 71.5d), Timeout, ct);
+
+ // …and now DDATA. The metric on the wire carries the PARENT's alias and no name at all, so a
+ // driver that flattened only at decode time — without resolving the parent alias to its name
+ // first — would have nothing to compose `Motor/Speed` from and would feed none of these.
+ await sim.PublishDeviceDataAsync(
+ device, [("Motor", MotorTemplate(speed: 1750, running: false, statorTemp: 68.25))], ct);
+
+ var data = await recorder.WaitAsync(speedPath, v => Equals(v.Value, 1750d), Timeout, ct);
+ data.Snapshot.StatusCode.ShouldBe(Good);
+ data.FullReference.ShouldBe(speedPath, "values publish under the tag's RawPath, never the metric name");
+
+ await recorder.WaitAsync(runningPath, v => Equals(v.Value, false), Timeout, ct);
+ await recorder.WaitAsync(statorPath, v => Equals(v.Value, 68.25d), Timeout, ct);
+
+ // The read path serves the same last values under the same keys.
+ var read = await driver.ReadAsync([speedPath, runningPath, statorPath], ct);
+ read.Select(r => r.StatusCode).ShouldAllBe(s => s == Good);
+ read[0].Value.ShouldBe(1750d);
+ }
+
+ [Fact]
+ public async Task A_dataset_metric_flattens_to_one_tag_per_cell()
+ {
+ SkipIfUnconfigured();
+
+ var ct = TestContext.Current.CancellationToken;
+ var group = NewGroup("dset");
+ const string node = "EdgeA";
+ const string device = "Filler1";
+
+ const string step0Path = "Fixture/Sparkplug/Recipe/Step0";
+ const string target1Path = "Fixture/Sparkplug/Recipe/Target1";
+
+ await using var sim = new SparkplugEdgeNode(SimOptions(group, node));
+ sim.SetDeviceMetrics(device, new SparkplugMetricSeed("Recipe", 14, DataType.DataSet, RecipeDataSet(baseTarget: 20d)));
+
+ await sim.ConnectAsync(ct);
+ await sim.BirthAsync(ct);
+
+ await using var driver = new MqttDriver(
+ SparkplugOptions(
+ group,
+ // The row index is always present, including on row 0 — the naming is uniform so a
+ // table that grows a row never renames the tags already authored against it.
+ Tag(step0Path, group, node, device, "Recipe/Step/0"),
+ Tag(target1Path, group, node, device, "Recipe/Target/1")),
+ "mqtt-live-sp-dataset");
+ var recorder = new Recorder(driver);
+
+ await driver.InitializeAsync(string.Empty, ct);
+ await driver.SubscribeAsync([step0Path, target1Path], TimeSpan.FromSeconds(1), ct);
+
+ // Cells are typed per COLUMN: Step is Int32, Target is Double. A dataset whose columns and
+ // types disagree is refused outright, so a wrong type here is not a near-miss — it is the
+ // difference between a typed tag and no tag.
+ await recorder.WaitAsync(step0Path, v => Equals(v.Value, 0), Timeout, ct);
+ await recorder.WaitAsync(target1Path, v => Equals(v.Value, 21d), Timeout, ct);
+
+ await sim.PublishDeviceDataAsync(device, [("Recipe", RecipeDataSet(baseTarget: 30d))], ct);
+
+ var target = await recorder.WaitAsync(target1Path, v => Equals(v.Value, 31d), Timeout, ct);
+ target.Snapshot.StatusCode.ShouldBe(Good);
+ }
+
+ [Fact]
+ public async Task A_template_definition_is_still_skipped_rather_than_flattened()
+ {
+ // Skip-and-warn survives — the requirement the issue is explicit about. A definition names its
+ // members and carries no values, so flattening it would materialise tags that can never read,
+ // indistinguishable in the picker from an instance's.
+ SkipIfUnconfigured();
+
+ var ct = TestContext.Current.CancellationToken;
+ var group = NewGroup("tdef");
+ const string node = "EdgeA";
+ const string device = "Filler1";
+
+ const string speedPath = "Fixture/Sparkplug/Proto/Speed";
+ const string livePath = "Fixture/Sparkplug/Proto/Live";
+
+ var definition = new Payload.Types.Template { Version = "1", IsDefinition = true };
+ definition.Metrics.Add(new Payload.Types.Metric { Name = "Speed", Datatype = (uint)DataType.Double });
+
+ await using var sim = new SparkplugEdgeNode(SimOptions(group, node));
+ sim.SetDeviceMetrics(
+ device,
+ new SparkplugMetricSeed("MotorType", 15, DataType.Template, definition),
+
+ // The positive control, and it is load-bearing: without it "Speed never arrived" is
+ // satisfied by any failure to connect, birth or subscribe, and the test would pass against
+ // a driver that ingests nothing at all.
+ new SparkplugMetricSeed("Live", 16, DataType.Double, 42.5d));
+
+ await sim.ConnectAsync(ct);
+ await sim.BirthAsync(ct);
+
+ await using var driver = new MqttDriver(
+ SparkplugOptions(
+ group,
+ Tag(speedPath, group, node, device, "MotorType/Speed"),
+ Tag(livePath, group, node, device, "Live")),
+ "mqtt-live-sp-tdef");
+ var recorder = new Recorder(driver);
+
+ await driver.InitializeAsync(string.Empty, ct);
+ await driver.SubscribeAsync([speedPath, livePath], TimeSpan.FromSeconds(1), ct);
+
+ // Wait for the control FIRST, so the absence assertion below is made at a moment the birth is
+ // known to have been processed.
+ await recorder.WaitAsync(livePath, v => Equals(v.Value, 42.5d), Timeout, ct);
+
+ recorder.Count(speedPath).ShouldBe(0, "a template definition carries no values to publish");
+
+ var read = await driver.ReadAsync([speedPath], ct);
+ read[0].StatusCode.ShouldNotBe(Good, "a tag bound to an unflattened member has never had a value");
+ }
+
+ // ---- helpers -----------------------------------------------------------------------------------
+
+ private static string NewGroup(string label) => $"OtOpcUaLive-{label}-{Guid.NewGuid():N}"[..40];
+
+ private static Payload.Types.Template MotorTemplate(double speed, bool running, double statorTemp)
+ {
+ var stator = new Payload.Types.Template { Version = "1", IsDefinition = false };
+ stator.Metrics.Add(new Payload.Types.Metric
+ {
+ Name = "Temp", Datatype = (uint)DataType.Double, DoubleValue = statorTemp,
+ });
+
+ var motor = new Payload.Types.Template { Version = "1", IsDefinition = false };
+ motor.Metrics.Add(new Payload.Types.Metric
+ {
+ Name = "Speed", Datatype = (uint)DataType.Double, DoubleValue = speed,
+ });
+ motor.Metrics.Add(new Payload.Types.Metric
+ {
+ Name = "Running", Datatype = (uint)DataType.Boolean, BooleanValue = running,
+ });
+ motor.Metrics.Add(new Payload.Types.Metric
+ {
+ Name = "Stator", Datatype = (uint)DataType.Template, TemplateValue = stator,
+ });
+ return motor;
+ }
+
+ private static Payload.Types.DataSet RecipeDataSet(double baseTarget)
+ {
+ var dataSet = new Payload.Types.DataSet { NumOfColumns = 2UL };
+ dataSet.Columns.Add("Step");
+ dataSet.Columns.Add("Target");
+ dataSet.Types_.Add((uint)DataType.Int32);
+ dataSet.Types_.Add((uint)DataType.Double);
+
+ for (var row = 0; row < 2; row++)
+ {
+ var r = new Payload.Types.DataSet.Types.Row();
+ r.Elements.Add(new Payload.Types.DataSet.Types.DataSetValue { IntValue = (uint)row });
+ r.Elements.Add(new Payload.Types.DataSet.Types.DataSetValue { DoubleValue = baseTarget + row });
+ dataSet.Rows.Add(r);
+ }
+
+ return dataSet;
+ }
+
+ private MqttDriverOptions SparkplugOptions(string groupId, params RawTagEntry[] rawTags) => new()
+ {
+ Host = fixture.TlsHost,
+ Port = fixture.TlsPort,
+ UseTls = true,
+ CaCertificatePath = fixture.CaCertificatePath,
+ AllowUntrustedServerCertificate = fixture.CaCertificatePath is null,
+ Username = fixture.Username,
+ Password = fixture.Password,
+ ClientId = $"otopcua-live-spx-{Guid.NewGuid():N}",
+ ConnectTimeoutSeconds = 15,
+ Mode = MqttMode.SparkplugB,
+ Sparkplug = new MqttSparkplugOptions { GroupId = groupId, RequestRebirthOnGap = true },
+ RawTags = rawTags,
+ };
+
+ private SparkplugEdgeNodeOptions SimOptions(string groupId, string edgeNodeId) => new()
+ {
+ Host = fixture.TlsHost,
+ Port = fixture.TlsPort,
+ UseTls = true,
+ CaCertificatePath = fixture.CaCertificatePath,
+ AllowUntrustedServerCertificate = fixture.CaCertificatePath is null,
+ Username = fixture.Username,
+ Password = fixture.Password,
+ GroupId = groupId,
+ EdgeNodeId = edgeNodeId,
+ };
+
+ private static RawTagEntry Tag(
+ string rawPath, string groupId, string edgeNodeId, string? deviceId, string metricName)
+ {
+ var device = deviceId is null ? "" : $"\"deviceId\":\"{deviceId}\",";
+ return new RawTagEntry(
+ rawPath,
+ $$"""{"groupId":"{{groupId}}","edgeNodeId":"{{edgeNodeId}}",{{device}}"metricName":"{{metricName}}"}""",
+ WriteIdempotent: false);
+ }
+
+ /// Records every published data change, so a test can wait on one and count others.
+ private sealed class Recorder
+ {
+ private readonly List _events = [];
+ private readonly Lock _gate = new();
+
+ public Recorder(MqttDriver driver) => driver.OnDataChange += (_, e) =>
+ {
+ lock (_gate)
+ {
+ _events.Add(e);
+ }
+ };
+
+ public int Count(string rawPath)
+ {
+ lock (_gate)
+ {
+ return _events.Count(e => e.FullReference == rawPath);
+ }
+ }
+
+ public async Task WaitAsync(
+ string rawPath, Func predicate, TimeSpan timeout, CancellationToken ct)
+ {
+ var deadline = DateTime.UtcNow + timeout;
+ while (true)
+ {
+ lock (_gate)
+ {
+ foreach (var candidate in _events)
+ {
+ if (candidate.FullReference == rawPath && predicate(candidate.Snapshot))
+ {
+ return candidate;
+ }
+ }
+ }
+
+ if (DateTime.UtcNow >= deadline)
+ {
+ lock (_gate)
+ {
+ throw new TimeoutException(
+ $"No matching value for '{rawPath}' within {timeout}. Seen: "
+ + string.Join(", ", _events.Select(e => $"{e.FullReference}={e.Snapshot.Value}")));
+ }
+ }
+
+ await Task.Delay(100, ct);
+ }
+ }
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowseSessionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowseSessionTests.cs
index 39cffaa9..5427e985 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowseSessionTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowseSessionTests.cs
@@ -486,6 +486,31 @@ public sealed class MqttBrowseSessionTests
s.PublishCountForTest.ShouldBe(0); // passive — browsing a live plant broker publishes nothing
}
+ [Fact]
+ public async Task SparkplugTree_TemplateMetric_OffersItsMembersAndNotItself()
+ {
+ // Gitea #515. The parent has no value and no servable datatype, so a leaf for `Motor` would
+ // offer the operator an address that can only ever commit a tag which never reads — and the
+ // picker gives no hint that one leaf is real and the other is not.
+ await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
+ s.ObserveTemplateBirthForTest(
+ "Plant1", "EdgeA", "Filler1", "Motor",
+ ("Speed", SparkplugDataType.Double),
+ ("Torque", SparkplugDataType.Double));
+
+ var metrics = await s.ExpandAsync("Plant1/EdgeA/Filler1", TestContext.Current.CancellationToken);
+
+ metrics.Select(n => n.DisplayName).ShouldBe(["Motor/Speed", "Motor/Torque"]);
+ metrics.ShouldNotContain(n => n.DisplayName == "Motor");
+ metrics[0].Kind.ShouldBe(BrowseNodeKind.Leaf);
+
+ // The metric name travels INTACT as the node id — it is the binding key. Only the derived tag
+ // Name is later coerced to a legal RawPath segment, by RawBrowseCommitMapper (Gitea #509).
+ metrics[0].NodeId.ShouldBe("Plant1/EdgeA/Filler1::Motor/Speed");
+
+ s.PublishCountForTest.ShouldBe(0);
+ }
+
[Fact]
public async Task SparkplugTree_NodeLevelMetrics_HangDirectlyUnderTheEdgeNode()
{
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugCodecTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugCodecTests.cs
index 77cbff89..aefaca6a 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugCodecTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugCodecTests.cs
@@ -186,35 +186,82 @@ public sealed class SparkplugCodecTests
}
///
- /// DataSet and Template metrics are out of scope for v1. They must decode to an
- /// explicit "unsupported" kind — never an exception on the MQTT dispatcher thread, and never a
- /// silently-dropped metric that looks identical to a metric that was never published.
+ /// A DataSet metric decodes to with its cells
+ /// flattened (Gitea #515) — never an exception on the MQTT dispatcher thread, and never a
+ /// silently-dropped metric that looks identical to one that was never published.
///
[Fact]
- public void Decode_GoldenNbirth_DataSetMetric_IsUnsupported_NotAThrow()
+ public void Decode_GoldenNbirth_DataSetMetric_FlattensToCells()
{
var payload = SparkplugCodec.Decode(SparkplugGoldenPayloads.Read(SparkplugGoldenPayloads.NbirthFile));
var config = payload.Metrics.Single(m => m.Name == "Config");
- config.ValueKind.ShouldBe(SparkplugValueKind.Unsupported);
+ config.ValueKind.ShouldBe(SparkplugValueKind.Structured);
+
+ // The PARENT carries no value — its members do. A parent that reported a value would be a
+ // guess about which cell "the" value is.
config.Value.ShouldBeNull();
config.DataType.ShouldBe(DataType.DataSet);
+
+ var cell = config.MemberList.Single();
+ cell.Path.ShouldBe("col0/0");
+ cell.DataType.ShouldBe(DataType.Int32);
+ cell.Value.ShouldBe(1U);
}
- /// A Template metric takes the same unsupported path as DataSet.
+ /// A Template metric flattens its members to sibling paths.
[Fact]
- public void Decode_TemplateMetric_IsUnsupported()
+ public void Decode_TemplateMetric_FlattensMembersToSiblingPaths()
{
+ var template = new Payload.Types.Template { Version = "1", IsDefinition = false };
+ template.Metrics.Add(new Payload.Types.Metric
+ {
+ Name = "Speed", Datatype = (uint)DataType.Double, DoubleValue = 12.5,
+ });
+ template.Metrics.Add(new Payload.Types.Metric
+ {
+ Name = "Running", Datatype = (uint)DataType.Boolean, BooleanValue = true,
+ });
+
var payload = new Payload();
payload.Metrics.Add(new Payload.Types.Metric
{
Name = "Motor",
Datatype = (uint)DataType.Template,
- TemplateValue = new Payload.Types.Template { Version = "1", IsDefinition = false },
+ TemplateValue = template,
});
- SparkplugCodec.Decode(payload.ToByteArray()).Metrics.Single().ValueKind
- .ShouldBe(SparkplugValueKind.Unsupported);
+ var metric = SparkplugCodec.Decode(payload.ToByteArray()).Metrics.Single();
+
+ metric.ValueKind.ShouldBe(SparkplugValueKind.Structured);
+ metric.MemberList.Select(m => m.Path).ShouldBe(["Speed", "Running"]);
+ metric.MemberList[0].Value.ShouldBe(12.5);
+ metric.MemberList[1].Value.ShouldBe(true);
+ }
+
+ ///
+ /// A template definition is refused rather than flattened, and refusal is still the
+ /// visible kind.
+ ///
+ [Fact]
+ public void Decode_TemplateDefinition_StaysUnsupported()
+ {
+ // A definition is the UDT's SHAPE, published so consumers can understand instances — its member
+ // metrics carry no values. Flattening it would materialise a parallel set of tags that can
+ // never read, indistinguishable in the picker from the real instance's.
+ var definition = new Payload.Types.Template { Version = "1", IsDefinition = true };
+ definition.Metrics.Add(new Payload.Types.Metric { Name = "Speed", Datatype = (uint)DataType.Double });
+
+ var payload = new Payload();
+ payload.Metrics.Add(new Payload.Types.Metric
+ {
+ Name = "MotorType", Datatype = (uint)DataType.Template, TemplateValue = definition,
+ });
+
+ var metric = SparkplugCodec.Decode(payload.ToByteArray()).Metrics.Single();
+
+ metric.ValueKind.ShouldBe(SparkplugValueKind.Unsupported);
+ metric.MemberList.ShouldBeEmpty();
}
/// Payload and per-metric timestamps survive as raw Sparkplug epoch milliseconds.
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugFlattenerTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugFlattenerTests.cs
new file mode 100644
index 00000000..5b63c931
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugFlattenerTests.cs
@@ -0,0 +1,294 @@
+using Google.Protobuf;
+using Org.Eclipse.Tahu.Protobuf;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
+using TahuDataType = Org.Eclipse.Tahu.Protobuf.DataType;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
+
+///
+/// Unit tests for — the Template/DataSet/PropertySet mapping
+/// (Gitea #515).
+///
+///
+/// Two properties dominate this file. First, what is flattened is addressable and correctly
+/// typed. Second — and the one that would be easy to lose in a later edit —
+/// skip-and-warn survives: anything that cannot be flattened honestly stays
+/// rather than becoming a guessed scalar, because a
+/// tag bound to data that is not what its name says is worse than a tag that never binds.
+///
+[Trait("Category", "Unit")]
+public sealed class SparkplugFlattenerTests
+{
+ // ---- Template -------------------------------------------------------------------------------
+
+ [Fact]
+ public void A_nested_template_flattens_to_slash_joined_paths()
+ {
+ // `/` is the separator Sparkplug itself uses for metric-name hierarchy, so `Motor/Stator/Temp`
+ // is exactly what an edge node would have called a flat metric of the same meaning.
+ var stator = Template(
+ Member("Temp", TahuDataType.Double, m => m.DoubleValue = 71.5),
+ Member("Current", TahuDataType.Float, m => m.FloatValue = 3.25f));
+
+ var motor = Template(
+ Member("Speed", TahuDataType.Double, m => m.DoubleValue = 1500),
+ MemberTemplate("Stator", stator));
+
+ SparkplugFlattener.TryFlattenTemplate(motor, out var members).ShouldBeTrue();
+
+ members.Select(m => m.Path).ShouldBe(["Speed", "Stator/Temp", "Stator/Current"]);
+ members[1].DataType.ShouldBe(TahuDataType.Double);
+ members[2].Value.ShouldBe(3.25f);
+ }
+
+ [Fact]
+ public void A_template_member_with_no_name_refuses_the_whole_metric()
+ {
+ // A template member is addressed by name; there is no alias fallback inside a template. Emitting
+ // the members that DO have names would publish a UDT silently missing part of itself, which
+ // reads in the picker exactly like a complete one.
+ var template = Template(
+ Member("Speed", TahuDataType.Double, m => m.DoubleValue = 1),
+ new Payload.Types.Metric { Datatype = (uint)TahuDataType.Double, DoubleValue = 2 });
+
+ SparkplugFlattener.TryFlattenTemplate(template, out var members).ShouldBeFalse();
+ members.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void An_extension_value_inside_a_template_refuses_the_whole_metric()
+ {
+ // An extension value is a third-party escape hatch with no defined semantics. There is nothing
+ // honest to flatten it into, and the sibling members cannot be trusted to be the whole story.
+ var template = Template(
+ Member("Speed", TahuDataType.Double, m => m.DoubleValue = 1),
+ Member("Odd", TahuDataType.Unknown, m => m.ExtensionValue = new Payload.Types.Metric.Types.MetricValueExtension()));
+
+ SparkplugFlattener.TryFlattenTemplate(template, out _).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void A_template_definition_is_refused()
+ {
+ // A definition carries the UDT's shape with no instance values — metadata for consumers, not
+ // signal. Flattening it would materialise tags that can never read.
+ var definition = Template(Member("Speed", TahuDataType.Double, _ => { }));
+ definition.IsDefinition = true;
+
+ SparkplugFlattener.TryFlattenTemplate(definition, out _).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void Nesting_past_the_depth_bound_refuses_the_whole_metric_rather_than_truncating()
+ {
+ // This walks an object graph anyone on the broker can publish, so the recursion must be bounded.
+ // Refusing whole beats truncating: a silently half-flattened UDT looks exactly like a complete
+ // one, and the operator would author tags against a subtree that quietly stops partway down.
+ var deepest = Template(Member("Leaf", TahuDataType.Double, m => m.DoubleValue = 1));
+ var current = deepest;
+ for (var i = 0; i < SparkplugFlattener.MaxDepth + 1; i++)
+ {
+ current = Template(MemberTemplate($"L{i}", current));
+ }
+
+ SparkplugFlattener.TryFlattenTemplate(current, out var members).ShouldBeFalse();
+ members.ShouldBeEmpty();
+
+ // Falsifying control: the same shape one level inside the bound flattens fine, so the refusal
+ // above is the bound doing its job rather than nesting being broken outright.
+ var shallow = Template(MemberTemplate("L0", deepest));
+ SparkplugFlattener.TryFlattenTemplate(shallow, out var ok).ShouldBeTrue();
+ ok.Single().Path.ShouldBe("L0/Leaf");
+ }
+
+ // ---- DataSet ---------------------------------------------------------------------------------
+
+ [Fact]
+ public void A_dataset_flattens_to_one_member_per_cell_with_the_row_index_always_present()
+ {
+ // Uniform naming, deliberately. Emitting a bare `{column}` for a single row and
+ // `{column}/{row}` otherwise would make a tag's own name depend on how many rows the plant
+ // happened to publish, so a table growing from one row to two would rename every existing tag
+ // out from under its authored binding.
+ var dataSet = DataSet(
+ columns: ["Temp", "Pressure"],
+ types: [TahuDataType.Double, TahuDataType.Int32],
+ rows:
+ [
+ [Cell(v => v.DoubleValue = 20.5), Cell(v => v.IntValue = 3)],
+ [Cell(v => v.DoubleValue = 21.0), Cell(v => v.IntValue = 4)],
+ ]);
+
+ SparkplugFlattener.TryFlattenDataSet(dataSet, out var members).ShouldBeTrue();
+
+ members.Select(m => m.Path).ShouldBe(["Temp/0", "Pressure/0", "Temp/1", "Pressure/1"]);
+ members[0].DataType.ShouldBe(TahuDataType.Double);
+ members[1].DataType.ShouldBe(TahuDataType.Int32);
+ members[3].Value.ShouldBe(4U);
+ }
+
+ [Fact]
+ public void A_single_row_dataset_still_carries_the_row_index()
+ => Flatten(DataSet(["Temp"], [TahuDataType.Double], [[Cell(v => v.DoubleValue = 1)]]))
+ .Single().Path.ShouldBe("Temp/0");
+
+ [Fact]
+ public void An_empty_cell_is_a_null_member_not_a_zero()
+ {
+ // A DataSetValue has no is_null field, so an empty oneof IS the null cell. Reading it as the
+ // proto default would publish 0 for a cell the plant left blank — plausible, Good, and wrong.
+ var members = Flatten(DataSet(["Temp"], [TahuDataType.Double], [[new Payload.Types.DataSet.Types.DataSetValue()]]));
+
+ members.Single().ValueKind.ShouldBe(SparkplugValueKind.Null);
+ members.Single().Value.ShouldBeNull();
+ }
+
+ [Fact]
+ public void A_column_and_type_count_mismatch_refuses_the_dataset()
+ // Columns and types are parallel arrays. Guessing which to trust would type a column by its
+ // neighbour, and every cell in it would publish at Good quality as the wrong type.
+ => SparkplugFlattener.TryFlattenDataSet(
+ DataSet(["Temp", "Pressure"], [TahuDataType.Double], [] ), out _)
+ .ShouldBeFalse();
+
+ [Fact]
+ public void A_short_row_refuses_the_dataset()
+ // Not a row of nulls: emitting the cells that ARE present publishes a table silently missing
+ // its right-hand columns.
+ => SparkplugFlattener.TryFlattenDataSet(
+ DataSet(["Temp", "Pressure"], [TahuDataType.Double, TahuDataType.Int32],
+ [[Cell(v => v.DoubleValue = 1)]]),
+ out _)
+ .ShouldBeFalse();
+
+ [Fact]
+ public void An_unnamed_column_refuses_the_dataset()
+ => SparkplugFlattener.TryFlattenDataSet(
+ DataSet(["", "Pressure"], [TahuDataType.Double, TahuDataType.Int32],
+ [[Cell(v => v.DoubleValue = 1), Cell(v => v.IntValue = 2)]]),
+ out _)
+ .ShouldBeFalse();
+
+ [Fact]
+ public void A_dataset_past_the_member_bound_refuses_rather_than_truncating()
+ {
+ var columns = Enumerable.Range(0, 8).Select(i => $"c{i}").ToArray();
+ var types = Enumerable.Repeat(TahuDataType.Int32, 8).ToArray();
+ var rows = Enumerable.Range(0, 100)
+ .Select(_ => Enumerable.Range(0, 8).Select(_ => Cell(v => v.IntValue = 1)).ToArray())
+ .ToArray();
+
+ // 800 cells, past MaxMembers (512).
+ SparkplugFlattener.TryFlattenDataSet(DataSet(columns, types, rows), out var members).ShouldBeFalse();
+ members.ShouldBeEmpty();
+ }
+
+ // ---- PropertySet ------------------------------------------------------------------------------
+
+ [Fact]
+ public void Properties_project_to_flat_metadata_including_one_nested_level()
+ {
+ var nested = new Payload.Types.PropertySet();
+ nested.Keys.Add("Low");
+ nested.Values.Add(new Payload.Types.PropertyValue { DoubleValue = 0 });
+
+ var set = new Payload.Types.PropertySet();
+ set.Keys.Add("engUnit");
+ set.Values.Add(new Payload.Types.PropertyValue { StringValue = "degC" });
+ set.Keys.Add("Range");
+ set.Values.Add(new Payload.Types.PropertyValue { PropertysetValue = nested });
+
+ var projected = SparkplugFlattener.ProjectProperties(set);
+
+ projected.Select(p => p.Name).ShouldBe(["engUnit", "Range/Low"]);
+ projected[0].Value.ShouldBe("degC");
+ }
+
+ [Fact]
+ public void Properties_never_become_members()
+ {
+ // The recorded decision, and the honest reading of the spec: a PropertySet describes a metric
+ // (units, ranges, quality hints) — it is not a value the metric HAS. Surfacing it as sibling
+ // tags would invent plant signals out of documentation.
+ var set = new Payload.Types.PropertySet();
+ set.Keys.Add("engUnit");
+ set.Values.Add(new Payload.Types.PropertyValue { StringValue = "degC" });
+
+ var payload = new Payload();
+ var metric = new Payload.Types.Metric
+ {
+ Name = "Temperature", Datatype = (uint)TahuDataType.Double, DoubleValue = 20.5, Properties = set,
+ };
+ payload.Metrics.Add(metric);
+
+ var decoded = SparkplugCodec.Decode(payload.ToByteArray()).Metrics.Single();
+
+ decoded.ValueKind.ShouldBe(SparkplugValueKind.Scalar);
+ decoded.MemberList.ShouldBeEmpty();
+ decoded.PropertyList.Single().Name.ShouldBe("engUnit");
+ }
+
+ [Fact]
+ public void A_malformed_property_set_projects_to_nothing_rather_than_throwing()
+ {
+ var set = new Payload.Types.PropertySet();
+ set.Keys.Add("orphan"); // no matching value
+
+ SparkplugFlattener.ProjectProperties(set).ShouldBeEmpty();
+ SparkplugFlattener.ProjectProperties(null).ShouldBeEmpty();
+ }
+
+ // ---- helpers ----------------------------------------------------------------------------------
+
+ private static IReadOnlyList Flatten(Payload.Types.DataSet dataSet)
+ {
+ SparkplugFlattener.TryFlattenDataSet(dataSet, out var members).ShouldBeTrue();
+ return members;
+ }
+
+ private static Payload.Types.Template Template(params Payload.Types.Metric[] members)
+ {
+ var template = new Payload.Types.Template { Version = "1", IsDefinition = false };
+ template.Metrics.AddRange(members);
+ return template;
+ }
+
+ private static Payload.Types.Metric Member(
+ string name, TahuDataType dataType, Action setValue)
+ {
+ var metric = new Payload.Types.Metric { Name = name, Datatype = (uint)dataType };
+ setValue(metric);
+ return metric;
+ }
+
+ private static Payload.Types.Metric MemberTemplate(string name, Payload.Types.Template value) =>
+ new() { Name = name, Datatype = (uint)TahuDataType.Template, TemplateValue = value };
+
+ private static Payload.Types.DataSet.Types.DataSetValue Cell(
+ Action setValue)
+ {
+ var cell = new Payload.Types.DataSet.Types.DataSetValue();
+ setValue(cell);
+ return cell;
+ }
+
+ private static Payload.Types.DataSet DataSet(
+ string[] columns,
+ TahuDataType[] types,
+ Payload.Types.DataSet.Types.DataSetValue[][] rows)
+ {
+ var dataSet = new Payload.Types.DataSet { NumOfColumns = (ulong)columns.Length };
+ dataSet.Columns.AddRange(columns);
+ dataSet.Types_.AddRange(types.Select(t => (uint)t));
+ foreach (var cells in rows)
+ {
+ var row = new Payload.Types.DataSet.Types.Row();
+ row.Elements.AddRange(cells);
+ dataSet.Rows.Add(row);
+ }
+
+ return dataSet;
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugGoldenPayloads.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugGoldenPayloads.cs
index 29b0544f..e052f7e4 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugGoldenPayloads.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugGoldenPayloads.cs
@@ -132,8 +132,9 @@ internal static class SparkplugGoldenPayloads
IntValue = unchecked((uint)CountBirthValue),
});
- // DataSet is explicitly out of scope for v1 — present here so "unsupported" is a decoded,
- // testable outcome rather than an exception on the MQTT dispatcher thread.
+ // A single-column, single-row DataSet. Gitea #515 flattens it to the addressable member
+ // `Config/col0/0`; before that it decoded as an explicit "unsupported" kind. Kept in the
+ // golden vector either way — it is the only structured metric on the wire-format path.
var dataSet = new Payload.Types.DataSet { NumOfColumns = 1UL };
dataSet.Columns.Add("col0");
dataSet.Types_.Add((uint)DataType.Int32);
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugPrimaryHostTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugPrimaryHostTests.cs
new file mode 100644
index 00000000..451a060c
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugPrimaryHostTests.cs
@@ -0,0 +1,263 @@
+using System.Text;
+using MQTTnet;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
+
+///
+/// Unit tests for the Sparkplug primary-host claim (Gitea #511) — the parts that are decidable
+/// without a broker: the STATE bodies, the Will registration, and the gate that decides whether a
+/// claim is attempted at all.
+///
+///
+/// The claim/release state machine itself needs a broker and is covered by
+/// MqttPrimaryHostLiveTests in the integration suite. What is pinned here is everything a
+/// live test could not tell apart from a working implementation by accident — above all that the
+/// birth and the Will carry the SAME timestamp, which a live test would see as two well-formed
+/// messages.
+///
+[Trait("Category", "Unit")]
+public sealed class SparkplugPrimaryHostTests
+{
+ private static MqttDriverOptions Options(
+ bool actAsPrimaryHost = true,
+ string hostId = "otopcua-primary",
+ MqttMode mode = MqttMode.SparkplugB)
+ => new()
+ {
+ Host = "broker.example",
+ Port = 1883,
+ ClientId = "otopcua",
+ Mode = mode,
+ Sparkplug = new MqttSparkplugOptions
+ {
+ GroupId = "OtOpcUaSim",
+ HostId = hostId,
+ ActAsPrimaryHost = actAsPrimaryHost,
+ },
+ };
+
+ // ---- The matched-timestamp invariant --------------------------------------------------------
+
+ [Fact]
+ public void The_birth_and_the_will_carry_the_same_timestamp()
+ {
+ // Sparkplug 3.0 pairs a host's birth and death certificates by their timestamp, so an edge node
+ // may ignore an OFFLINE older than the ONLINE it holds. The two bodies are necessarily built at
+ // different moments — the Will goes in the CONNECT packet, the birth is published after CONNACK
+ // — so reading the clock twice is the natural mistake, and it produces two individually
+ // well-formed messages that no live test would flag.
+ var session = SparkplugStatePayload.ForSession("otopcua-primary", 1_770_000_000_123);
+
+ SparkplugStatePayload.TryDecode(session.OnlineBody, out var online, out var birthTs).ShouldBeTrue();
+ SparkplugStatePayload.TryDecode(session.OfflineBody, out var offline, out var deathTs).ShouldBeTrue();
+
+ online.ShouldBeTrue();
+ offline.ShouldBeFalse();
+ birthTs.ShouldBe(1_770_000_000_123);
+ deathTs.ShouldBe(birthTs);
+ }
+
+ [Fact]
+ public void The_state_topic_is_the_v3_form()
+ => SparkplugStatePayload.ForSession("scada-primary", 1).Topic.ShouldBe("spBv1.0/STATE/scada-primary");
+
+ [Fact]
+ public void The_timestamp_is_a_bare_json_number()
+ {
+ // A quoted timestamp still parses as a valid JSON object, so a serializer choosing string
+ // formatting for a long would be silently wrong to every conformant host.
+ var body = Encoding.UTF8.GetString(SparkplugStatePayload.Encode(online: true, 1_770_000_000_123));
+
+ body.ShouldBe("{\"online\":true,\"timestamp\":1770000000123}");
+ }
+
+ [Fact]
+ public void The_body_survives_a_non_invariant_culture()
+ {
+ var previous = Thread.CurrentThread.CurrentCulture;
+ Thread.CurrentThread.CurrentCulture = new System.Globalization.CultureInfo("de-DE");
+ try
+ {
+ Encoding.UTF8.GetString(SparkplugStatePayload.Encode(online: false, 1_770_000_000_123))
+ .ShouldBe("{\"online\":false,\"timestamp\":1770000000123}");
+ }
+ finally
+ {
+ Thread.CurrentThread.CurrentCulture = previous;
+ }
+ }
+
+ // ---- The Will actually reaches the CONNECT packet --------------------------------------------
+
+ [Fact]
+ public void The_will_is_registered_on_the_client_options_retained_at_qos1()
+ {
+ // The single most important assertion in this file. A retained ONLINE with no Will behind it is
+ // strictly worse than publishing nothing: a crashed node then asserts forever that a dead host
+ // is alive. This is the check that the death certificate is armed at CONNECT rather than merely
+ // constructed.
+ var session = SparkplugStatePayload.ForSession("otopcua-primary", 42);
+ var will = new MqttLastWill(session.Topic, session.OfflineBody, SparkplugStatePayload.StateQos, Retain: true);
+
+ var built = MqttClientOptionsFactory.BuildClientOptions(
+ Options(), SparkplugPrimaryHost.ClientIdSuffix, logger: null, lastWill: will, cleanSessionOverride: true);
+
+ built.WillTopic.ShouldBe("spBv1.0/STATE/otopcua-primary");
+ built.WillRetain.ShouldBeTrue();
+ built.WillQualityOfServiceLevel.ShouldBe(MQTTnet.Protocol.MqttQualityOfServiceLevel.AtLeastOnce);
+ SparkplugStatePayload.TryDecode(built.WillPayload, out var online, out _).ShouldBeTrue();
+ online.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void No_will_is_registered_when_none_is_supplied()
+ // Every other connection this repo opens — ingest, browse, probe — must stay Will-free. A Will
+ // leaking onto the ingest session would publish a host death when ingest merely reconnects.
+ => MqttClientOptionsFactory.BuildClientOptions(Options(), clientIdSuffix: null).WillTopic.ShouldBeNull();
+
+ [Fact]
+ public void The_claim_connection_forces_clean_session_over_the_configured_value()
+ {
+ // A broker resuming a previous session for this client id may keep the OLD session's Will and
+ // discard the one this CONNECT carries — a claim with no death certificate behind it.
+ var persistent = Options() with { CleanSession = false };
+
+ MqttClientOptionsFactory.BuildClientOptions(persistent, "-spbhost", cleanSessionOverride: true)
+ .CleanSession.ShouldBeTrue();
+
+ // ...and the override is genuinely an override, not a constant.
+ MqttClientOptionsFactory.BuildClientOptions(persistent, clientIdSuffix: null)
+ .CleanSession.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void The_claim_connection_uses_a_distinct_client_id()
+ {
+ // Two MQTT clients presenting one client id is not an error the broker reports: it evicts the
+ // older one. Ingest and the claim would take turns killing each other, forever, looking like a
+ // flapping broker.
+ var ingest = MqttClientOptionsFactory.BuildClientOptions(Options(), clientIdSuffix: null).ClientId;
+ var claim = MqttClientOptionsFactory
+ .BuildClientOptions(Options(), SparkplugPrimaryHost.ClientIdSuffix).ClientId;
+
+ claim.ShouldNotBe(ingest);
+ claim.ShouldBe("otopcua-spbhost");
+ }
+
+ // ---- Whether a claim is attempted at all -----------------------------------------------------
+
+ [Fact]
+ public void A_configured_claim_is_claimable()
+ => SparkplugPrimaryHost.CanClaim(Options()).ShouldBeTrue();
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void A_blank_host_id_is_refused_rather_than_publishing_to_a_malformed_topic(string hostId)
+ {
+ // "spBv1.0/STATE/" is a legal MQTT topic, so a publish would succeed and reach nobody.
+ SparkplugPrimaryHost.CanClaim(Options(hostId: hostId)).ShouldBeFalse();
+ SparkplugPrimaryHost.DescribeUnusable(Options(hostId: hostId)).ShouldNotBeNullOrWhiteSpace();
+ }
+
+ [Fact]
+ public void The_flag_is_ignored_outside_sparkplug_mode_and_says_so()
+ {
+ var plain = Options(mode: MqttMode.Plain);
+
+ SparkplugPrimaryHost.CanClaim(plain).ShouldBeFalse();
+ SparkplugPrimaryHost.DescribeUnusable(plain).ShouldNotBeNullOrWhiteSpace();
+ }
+
+ [Fact]
+ public void An_unset_flag_produces_no_complaint()
+ {
+ // DescribeUnusable must stay silent about a configuration that never asked for anything, or the
+ // driver warns on every ordinary Sparkplug deployment.
+ SparkplugPrimaryHost.CanClaim(Options(actAsPrimaryHost: false)).ShouldBeFalse();
+ SparkplugPrimaryHost.DescribeUnusable(Options(actAsPrimaryHost: false)).ShouldBeNull();
+ SparkplugPrimaryHost.DescribeUnusable(Options(actAsPrimaryHost: false, hostId: "")).ShouldBeNull();
+ }
+
+ [Fact]
+ public void Constructing_a_claim_without_a_host_id_throws_rather_than_half_working()
+ => Should.Throw(() =>
+ new SparkplugPrimaryHost(Options(hostId: " "), "drv-1", UnmanagedDriverPrimaryRoleSource.Instance));
+
+ // ---- The gate ---------------------------------------------------------------------------------
+
+ [Fact]
+ public void A_driver_with_no_role_source_claims()
+ {
+ // The documented fallback for a standalone host or a test: with nothing publishing a role there
+ // is no peer to collide with, and defaulting closed would make the feature silently inert on the
+ // single-node deployment that is its commonest use.
+ UnmanagedDriverPrimaryRoleSource.Instance.ShouldActAsPrimary.ShouldBeTrue();
+
+ // Publishing into it is a no-op rather than a throw, so a caller need not special-case it.
+ UnmanagedDriverPrimaryRoleSource.Instance.PublishPrimaryRole(false);
+ UnmanagedDriverPrimaryRoleSource.Instance.ShouldActAsPrimary.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task A_secondary_never_even_attempts_a_claim_connection()
+ {
+ // The whole point of gating. Asserted on ATTEMPTS, not on IsClaimed: both nodes point at the
+ // same unreachable broker here, so IsClaimed is false either way and an IsClaimed-only
+ // assertion would pass just as happily with the gate deleted. See ClaimAttemptsForTests.
+ var host = new SparkplugPrimaryHost(
+ Unreachable(), "drv-1", new FixedRole(shouldActAsPrimary: false));
+
+ await host.StartAsync(TestContext.Current.CancellationToken);
+
+ host.ClaimAttemptsForTests.ShouldBe(0);
+ host.IsClaimed.ShouldBeFalse();
+ await host.DisposeAsync();
+ }
+
+ [Fact]
+ public async Task A_primary_attempts_the_claim_and_survives_a_broker_it_cannot_reach()
+ {
+ // The falsifying control for the test above — same options, same broker, opposite verdict — so
+ // "0 attempts" there is a fact about the gate rather than about the fixture.
+ //
+ // It also pins the swallow: the claim is advisory and ingest is the data plane, so a broker that
+ // refuses it must cost a warning, not the driver. And IsClaimed must stay honest — a connection
+ // whose ONLINE never landed is not a claim.
+ var host = new SparkplugPrimaryHost(Unreachable(), "drv-1", new FixedRole(shouldActAsPrimary: true));
+
+ await Should.NotThrowAsync(() => host.StartAsync(TestContext.Current.CancellationToken));
+
+ host.ClaimAttemptsForTests.ShouldBe(1);
+ host.IsClaimed.ShouldBeFalse();
+ await host.DisposeAsync();
+ }
+
+ /// A broker that refuses instantly, so a connect attempt is observable but not slow.
+ private static MqttDriverOptions Unreachable() =>
+ Options() with { Host = "127.0.0.1", Port = 1, ConnectTimeoutSeconds = 1 };
+
+ [Fact]
+ public async Task Stopping_an_unclaimed_host_is_a_no_op()
+ {
+ var host = new SparkplugPrimaryHost(Options(), "drv-1", new FixedRole(shouldActAsPrimary: false));
+
+ await Should.NotThrowAsync(() => host.StopAsync(TestContext.Current.CancellationToken));
+
+ // Twice: teardown runs from both ShutdownAsync and DisposeAsync, so a second release that threw
+ // — or that published a second OFFLINE — would surface on every ordinary driver shutdown.
+ await Should.NotThrowAsync(async () => await host.DisposeAsync());
+ await Should.NotThrowAsync(async () => await host.DisposeAsync());
+ }
+
+ private sealed class FixedRole(bool shouldActAsPrimary) : IDriverPrimaryRoleSource
+ {
+ public bool ShouldActAsPrimary { get; private set; } = shouldActAsPrimary;
+
+ public void PublishPrimaryRole(bool value) => ShouldActAsPrimary = value;
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugStructuredIngestTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugStructuredIngestTests.cs
new file mode 100644
index 00000000..d99be5df
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugStructuredIngestTests.cs
@@ -0,0 +1,283 @@
+using Google.Protobuf;
+using Org.Eclipse.Tahu.Protobuf;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
+using TahuDataType = Org.Eclipse.Tahu.Protobuf.DataType;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
+
+///
+/// End-to-end ingest of flattened structured metrics (Gitea #515): a tag authored against
+/// Motor/Speed is fed by a DBIRTH/DDATA carrying a Template metric named
+/// Motor.
+///
+///
+/// pins the mapping in isolation; this file pins that the
+/// chain actually connects — flatten → alias-table binding → authored-tag lookup → publish. Those
+/// are four separate places a member name is composed, and a mismatch in any one of them produces
+/// a driver that decodes structured metrics perfectly and feeds nothing.
+///
+[Trait("Category", "Unit")]
+public sealed class SparkplugStructuredIngestTests
+{
+ private const string Group = "Plant1";
+ private const string Node = "EdgeA";
+ private const string Device = "Filler1";
+ private const ulong MotorAlias = 20UL;
+ private const ulong TableAlias = 21UL;
+ private const uint StatusGood = 0x00000000u;
+
+ private const string SpeedPath = "Plant/Mqtt/spb/MotorSpeed";
+ private const string TempCellPath = "Plant/Mqtt/spb/TempCell0";
+
+ [Fact]
+ public async Task A_template_member_feeds_the_tag_authored_against_its_flattened_name()
+ {
+ // The whole point of #515, asserted end to end. `Motor` is what the wire carries; `Motor/Speed`
+ // is what an operator authors, and nothing on the wire is ever called that.
+ var (ing, fired) = await NewIngestorAsync(Tag(SpeedPath, Blob("Motor/Speed", "Float64")));
+
+ ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth());
+ ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Birth(seq: 1, MotorTemplateMetric(speed: 1500, running: true)));
+
+ fired.ShouldContainKey(SpeedPath);
+ fired[SpeedPath].Value.ShouldBe(1500d);
+ fired[SpeedPath].StatusCode.ShouldBe(StatusGood);
+ }
+
+ [Fact]
+ public async Task A_ddata_template_carrying_only_the_parents_alias_still_reaches_the_member_tag()
+ {
+ // The load-bearing case, and the one a naive implementation gets wrong. A DDATA metric carries
+ // an ALIAS and no name — so the member names cannot be composed until the alias table has
+ // resolved the parent's name. Flattening at decode time alone is not enough.
+ var (ing, fired) = await NewIngestorAsync(Tag(SpeedPath, Blob("Motor/Speed", "Float64")));
+
+ ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth());
+ ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Birth(seq: 1, MotorTemplateMetric(speed: 1500, running: true)));
+ fired.Clear();
+
+ // seq 2, alias only — no Name on the metric at all.
+ var data = new Payload { Seq = 2UL, Timestamp = 1721822405000UL };
+ data.Metrics.Add(new Payload.Types.Metric
+ {
+ Alias = MotorAlias,
+ TemplateValue = TemplateOf(speed: 1750, running: false),
+ });
+
+ ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Decode(data));
+
+ fired.ShouldContainKey(SpeedPath);
+ fired[SpeedPath].Value.ShouldBe(1750d);
+ }
+
+ [Fact]
+ public async Task A_dataset_cell_feeds_the_tag_authored_against_its_indexed_name()
+ {
+ var (ing, fired) = await NewIngestorAsync(Tag(TempCellPath, Blob("Table/Temp/0", "Float64")));
+
+ ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth());
+ ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Birth(seq: 1, TableDataSetMetric(20.5, 21.5)));
+
+ fired.ShouldContainKey(TempCellPath);
+ fired[TempCellPath].Value.ShouldBe(20.5d);
+ }
+
+ [Fact]
+ public async Task A_member_that_carries_no_datatype_falls_back_to_the_birth_binding()
+ {
+ // A DDATA template is not obliged to re-declare its members' datatypes. Guessing from the CLR
+ // type of the wire value would read a signed Int32 of -42 out of its unsigned field as
+ // 4294967254 — plausible, Good quality, and wrong.
+ var (ing, fired) = await NewIngestorAsync(Tag(SpeedPath, Blob("Motor/Offset", "Int32")));
+
+ var birthTemplate = new Payload.Types.Template { Version = "1", IsDefinition = false };
+ birthTemplate.Metrics.Add(new Payload.Types.Metric
+ {
+ Name = "Offset", Datatype = (uint)TahuDataType.Int32, IntValue = unchecked((uint)-42),
+ });
+
+ ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth());
+ ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Birth(seq: 1, new Payload.Types.Metric
+ {
+ Name = "Motor", Alias = MotorAlias, Datatype = (uint)TahuDataType.Template, TemplateValue = birthTemplate,
+ }));
+
+ fired[SpeedPath].Value.ShouldBe(-42);
+ fired.Clear();
+
+ // DDATA member with NO datatype of its own.
+ var dataTemplate = new Payload.Types.Template { Version = "1", IsDefinition = false };
+ dataTemplate.Metrics.Add(new Payload.Types.Metric { Name = "Offset", IntValue = unchecked((uint)-7) });
+
+ var data = new Payload { Seq = 2UL, Timestamp = 1721822405000UL };
+ data.Metrics.Add(new Payload.Types.Metric { Alias = MotorAlias, TemplateValue = dataTemplate });
+ ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Decode(data));
+
+ // -7, not 4294967289: the birth's Int32 binding drove ReinterpretSigned.
+ fired[SpeedPath].Value.ShouldBe(-7);
+ }
+
+ [Fact]
+ public async Task A_tag_authored_against_the_structured_parent_itself_is_never_fed()
+ {
+ // The parent has no value — its members do. Feeding it anything would require choosing which
+ // member "the" value is, which is exactly the guess #515 refuses to make.
+ var (ing, fired) = await NewIngestorAsync(Tag(SpeedPath, Blob("Motor", "Float64")));
+
+ ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth());
+ ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Birth(seq: 1, MotorTemplateMetric(speed: 1500, running: true)));
+
+ fired.ShouldNotContainKey(SpeedPath);
+ }
+
+ [Fact]
+ public async Task An_unflattenable_structured_metric_still_feeds_nothing_rather_than_a_guess()
+ {
+ // Skip-and-warn survives — the explicit requirement on the issue. A template DEFINITION is the
+ // realistic instance of this: it names members and carries no values.
+ var definition = new Payload.Types.Template { Version = "1", IsDefinition = true };
+ definition.Metrics.Add(new Payload.Types.Metric { Name = "Speed", Datatype = (uint)TahuDataType.Double });
+
+ var (ing, fired) = await NewIngestorAsync(Tag(SpeedPath, Blob("Motor/Speed", "Float64")));
+
+ ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth());
+ ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Birth(seq: 1, new Payload.Types.Metric
+ {
+ Name = "Motor", Alias = MotorAlias, Datatype = (uint)TahuDataType.Template, TemplateValue = definition,
+ }));
+
+ fired.ShouldNotContainKey(SpeedPath);
+ }
+
+ [Fact]
+ public async Task The_rediscovery_signature_tracks_members_not_the_parent_metric_name()
+ {
+ // A UDT gaining or losing a member leaves the PARENT metric's name identical, so a gate built
+ // on raw wire names would never move — and the operator would never be told the tree changed,
+ // which for a UDT is the change that matters most.
+ var (ing, _) = await NewIngestorAsync(Tag(SpeedPath, Blob("Motor/Speed", "Float64")));
+
+ var observed = new List>();
+ ing.BirthObserved += (_, e) => observed.Add(e.MetricNames);
+
+ ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth());
+ ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Birth(seq: 1, MotorTemplateMetric(speed: 1, running: true)));
+
+ var deviceBirth = observed[^1];
+ deviceBirth.ShouldBe(["Motor/Speed", "Motor/Running"], ignoreOrder: true);
+ deviceBirth.ShouldNotContain("Motor");
+ }
+
+ // ---- helpers ---------------------------------------------------------------------------------
+
+ ///
+ /// A registered and subscribed ingestor. The subscribe step is not optional scaffolding:
+ /// SparkplugIngestor.Raise only fires OnDataChange for a RawPath that holds a
+ /// subscription handle, so an unsubscribed ingestor caches values silently and every assertion
+ /// in this file would read as "no value" whether or not the flattening worked.
+ ///
+ private static async Task<(SparkplugIngestor Ingestor, Dictionary Fired)> NewIngestorAsync(
+ params RawTagEntry[] tags)
+ {
+ var options = new MqttDriverOptions
+ {
+ Mode = MqttMode.SparkplugB,
+ Sparkplug = new MqttSparkplugOptions { GroupId = Group, RequestRebirthOnGap = false },
+ RawTags = tags,
+ };
+
+ var ingestor = new SparkplugIngestor(
+ options,
+ "drv-1",
+ subscribeTransport: new GrantingSubscribeTransport(),
+ publishTransport: null,
+ logger: null,
+ cache: new LastValueCache());
+ ingestor.Register(tags);
+ await ingestor.SubscribeAsync(
+ [.. ingestor.AuthoredRawPaths], TimeSpan.Zero, TestContext.Current.CancellationToken);
+
+ var fired = new Dictionary(StringComparer.Ordinal);
+ ingestor.OnDataChange += (_, e) => fired[e.FullReference] = e.Snapshot;
+ return (ingestor, fired);
+ }
+
+ /// A SUBACK that grants everything; the tests here are about values, not subscribe policy.
+ private sealed class GrantingSubscribeTransport : IMqttSubscribeTransport
+ {
+ public Task> SubscribeAsync(
+ IReadOnlyList filters, CancellationToken cancellationToken)
+ => Task.FromResult>(
+ [.. filters.Select(f => new MqttSubscribeOutcome(f.Topic, true, "GrantedQoS1"))]);
+ }
+
+ private static RawTagEntry Tag(string rawPath, string tagConfig) => new(rawPath, tagConfig, WriteIdempotent: false);
+
+ private static string Blob(string metric, string dataType) =>
+ $$"""{"groupId":"{{Group}}","edgeNodeId":"{{Node}}","deviceId":"{{Device}}","metricName":"{{metric}}","dataType":"{{dataType}}"}""";
+
+ private static SparkplugTopic Topic(SparkplugMessageType type, string? device = null) =>
+ new(type, Group, Node, device, null);
+
+ private static SparkplugPayload Decode(Payload payload) => SparkplugCodec.Decode(payload.ToByteArray());
+
+ private static SparkplugPayload Nbirth()
+ {
+ var payload = new Payload { Seq = 0UL, Timestamp = 1721822400000UL };
+ payload.Metrics.Add(new Payload.Types.Metric
+ {
+ Name = "bdSeq", Datatype = (uint)TahuDataType.Uint64, LongValue = 1UL,
+ });
+ return Decode(payload);
+ }
+
+ private static SparkplugPayload Birth(ulong seq, params Payload.Types.Metric[] metrics)
+ {
+ var payload = new Payload { Seq = seq, Timestamp = 1721822400000UL };
+ payload.Metrics.AddRange(metrics);
+ return Decode(payload);
+ }
+
+ private static Payload.Types.Template TemplateOf(double speed, bool running)
+ {
+ var template = new Payload.Types.Template { Version = "1", IsDefinition = false };
+ template.Metrics.Add(new Payload.Types.Metric
+ {
+ Name = "Speed", Datatype = (uint)TahuDataType.Double, DoubleValue = speed,
+ });
+ template.Metrics.Add(new Payload.Types.Metric
+ {
+ Name = "Running", Datatype = (uint)TahuDataType.Boolean, BooleanValue = running,
+ });
+ return template;
+ }
+
+ private static Payload.Types.Metric MotorTemplateMetric(double speed, bool running) => new()
+ {
+ Name = "Motor",
+ Alias = MotorAlias,
+ Datatype = (uint)TahuDataType.Template,
+ TemplateValue = TemplateOf(speed, running),
+ };
+
+ private static Payload.Types.Metric TableDataSetMetric(params double[] temps)
+ {
+ var dataSet = new Payload.Types.DataSet { NumOfColumns = 1UL };
+ dataSet.Columns.Add("Temp");
+ dataSet.Types_.Add((uint)TahuDataType.Double);
+ foreach (var temp in temps)
+ {
+ var row = new Payload.Types.DataSet.Types.Row();
+ row.Elements.Add(new Payload.Types.DataSet.Types.DataSetValue { DoubleValue = temp });
+ dataSet.Rows.Add(row);
+ }
+
+ return new Payload.Types.Metric
+ {
+ Name = "Table", Alias = TableAlias, Datatype = (uint)TahuDataType.DataSet, DatasetValue = dataSet,
+ };
+ }
+}