diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinition.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinition.cs
index d4f6663b..15c3c3eb 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinition.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinition.cs
@@ -9,12 +9,20 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
/// See docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md §5.2 / §5.3.
///
/// The record carries both ingest shapes: the Plain-MQTT fields
-/// ( … ), populated in P1, and the Sparkplug B
-/// descriptor fields (, ,
-/// , ), which are deliberate stubs in P1
-/// — always until the P2 tasks (15–26) teach the parser to read them.
-/// A Sparkplug tag is resolved by the stable (group, node, device, metricName) tuple,
-/// never by the per-birth metric alias.
+/// ( … ), populated by
+/// , and the Sparkplug B descriptor fields
+/// (, , ,
+/// ), populated by
+/// (Task 21). A Sparkplug tag is
+/// resolved by the stable (group, node, device, metricName) tuple, never by the
+/// per-birth metric alias — an alias may be reused across a rebirth for a different metric.
+///
+///
+/// Which factory runs is chosen by the driver's , never sniffed from
+/// the blob. A blob carrying both shapes' keys is legal (the AdminUI editor preserves
+/// unknown keys through a load→save, so a tag retyped from Plain to Sparkplug keeps its old
+/// topic), and a heuristic would make the same authored tag mean two different things
+/// depending on which key happened to survive.
///
///
///
@@ -44,15 +52,23 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
/// Whether the broker's retained message for seeds this tag's initial value
/// on subscribe (the OPC UA initial-data convention). Defaults to .
///
-/// P2 stub — the Sparkplug group id. Always in P1.
-/// P2 stub — the Sparkplug edge-node id. Always in P1.
+///
+/// Whether came from an authored dataType key or is merely this
+/// record's default. Load-bearing in Sparkplug mode only, where dataType is optional:
+/// a Sparkplug metric's type is declared by its own birth certificate, so an unauthored tag must
+/// take the type the NBIRTH/DBIRTH declared rather than silently coercing every value to the
+/// default. Both factories set it from the key's presence so it
+/// means the same thing in either mode; nothing on the Plain ingest path reads it.
+///
+/// The Sparkplug group id; for a Plain-mode tag.
+/// The Sparkplug edge-node id; for a Plain-mode tag.
///
-/// P2 stub — the Sparkplug device id (absent for node-level metrics). Always
-/// in P1.
+/// The Sparkplug device id, or for a metric published by the edge node
+/// itself (and for every Plain-mode tag).
///
///
-/// P2 stub — the Sparkplug metric name, the tag's stable identity across rebirths. Always
-/// in P1.
+/// The Sparkplug metric name — the tag's stable identity across rebirths, and the key the ingest
+/// state machine binds by. for a Plain-mode tag.
///
public sealed record MqttTagDefinition(
string Name,
@@ -62,6 +78,7 @@ public sealed record MqttTagDefinition(
DriverDataType DataType,
int? Qos,
bool RetainSeed,
+ bool DataTypeAuthored = true,
string? GroupId = null,
string? EdgeNodeId = null,
string? DeviceId = null,
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinitionFactory.cs
index 697256b1..239d84ab 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinitionFactory.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinitionFactory.cs
@@ -20,9 +20,13 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
/// deploy instead of silently going dark at runtime.
///
///
-/// Sparkplug-descriptor parsing (groupId / edgeNodeId / deviceId /
-/// metricName) is a deliberate P1 stub — the fields exist on
-/// but are never populated here until the P2 tasks fill them.
+/// Two runtime entry points, one per ingest shape — (Plain)
+/// and (Sparkplug B). The caller picks by the driver's
+/// ; neither sniffs the blob. That is deliberate: the AdminUI editor
+/// preserves unknown keys through a load→save, so a tag retyped from Plain to Sparkplug keeps
+/// its old topic and a presence heuristic would make one authored blob mean two
+/// different things depending on which key happened to survive. The driver's mode is
+/// the single authority.
///
///
/// No ToTagConfig inverse — a deliberate YAGNI call. The six sibling factories
@@ -80,6 +84,7 @@ public static class MqttTagDefinitionFactory
// (→ BadNodeIdUnknown) instead of silently defaulting to a wrong-typed Good.
if (!TagConfigJson.TryReadEnumStrict(root, "payloadFormat", MqttPayloadFormat.Json, out var payloadFormat))
return false;
+ var dataTypeAuthored = root.TryGetProperty("dataType", out _);
if (!TagConfigJson.TryReadEnumStrict(root, "dataType", DriverDataType.String, out var dataType))
return false;
@@ -98,7 +103,94 @@ public static class MqttTagDefinitionFactory
JsonPath: jsonPath,
DataType: dataType,
Qos: qos,
- RetainSeed: ReadBoolOrDefault(root, "retainSeed", defaultValue: true));
+ RetainSeed: ReadBoolOrDefault(root, "retainSeed", defaultValue: true),
+ DataTypeAuthored: dataTypeAuthored);
+ return true;
+ }
+ catch (JsonException) { return false; }
+ catch (FormatException) { return false; }
+ catch (InvalidOperationException) { return false; }
+ }
+
+ ///
+ /// Maps an authored TagConfig object to a Sparkplug B definition keyed by
+ /// . The tag's binding identity is the stable
+ /// (groupId, edgeNodeId, deviceId?, metricName) tuple; deviceId is optional (absent
+ /// means the metric is published by the edge node itself), and there is no topic —
+ /// a Sparkplug driver subscribes one group-wide filter and routes by the decoded topic + birth
+ /// certificate, never by a per-tag topic. Never throws.
+ ///
+ ///
+ ///
+ /// dataType is OPTIONAL here, unlike in Plain mode. A Sparkplug metric declares
+ /// its own datatype in its birth certificate, so an unauthored tag legitimately takes the
+ /// type the birth declares — that is the whole point of Task 22's UntilStable
+ /// discovery. It is still read strictly when present (a typo'd value rejects the tag,
+ /// exactly as in Plain mode) and the outcome is recorded on
+ /// so the ingest path can tell "the operator
+ /// declared String" from "nobody declared anything and the record defaulted".
+ ///
+ ///
+ /// Plain-shape keys are read but not required. topic/payloadFormat/
+ /// jsonPath are meaningless to the Sparkplug ingest path and are deliberately NOT
+ /// validated — a blob retyped in the editor keeps them, and rejecting the tag for a stale
+ /// leftover key would take a correctly-authored Sparkplug tag dark with no stated cause.
+ ///
+ ///
+ /// The authored equipment-tag TagConfig JSON.
+ /// The tag's RawPath — becomes the definition's identity (Name).
+ /// The mapped definition when this returns .
+ /// when the blob carries a usable Sparkplug descriptor.
+ public static bool FromSparkplugTagConfig(string tagConfig, string rawPath, out MqttTagDefinition def)
+ {
+ def = null!;
+ if (string.IsNullOrWhiteSpace(tagConfig) || tagConfig[0] != '{') return false;
+ try
+ {
+ using var doc = JsonDocument.Parse(tagConfig);
+ var root = doc.RootElement;
+ if (root.ValueKind != JsonValueKind.Object) return false;
+
+ // The binding tuple. Without all three of these there is nothing a birth certificate could
+ // ever bind the tag to, so an absent/blank value is a hard reject (→ BadNodeIdUnknown)
+ // rather than a definition that can never resolve and reports nothing about why.
+ var groupId = ReadString(root, "groupId");
+ var edgeNodeId = ReadString(root, "edgeNodeId");
+ var metricName = ReadString(root, "metricName");
+ if (string.IsNullOrWhiteSpace(groupId)
+ || string.IsNullOrWhiteSpace(edgeNodeId)
+ || string.IsNullOrWhiteSpace(metricName))
+ {
+ return false;
+ }
+
+ // Optional: a node-level metric has no device segment. Blank normalises to absent so
+ // "deviceId":"" and an omitted key describe the same scope rather than two.
+ var deviceId = ReadString(root, "deviceId");
+ if (string.IsNullOrWhiteSpace(deviceId)) deviceId = null;
+
+ // Strict, but only when present — see the remarks.
+ var dataTypeAuthored = root.TryGetProperty("dataType", out _);
+ if (!TagConfigJson.TryReadEnumStrict(root, "dataType", DriverDataType.String, out var dataType))
+ return false;
+
+ if (!TryReadQosStrict(root, out var qos)) return false;
+
+ def = new MqttTagDefinition(
+ Name: rawPath,
+ // Plain-shape fields carried through verbatim; the Sparkplug ingest path reads none of
+ // them, and a retyped blob's leftovers must not change what the tag means.
+ Topic: ReadString(root, "topic"),
+ PayloadFormat: MqttPayloadFormat.Json,
+ JsonPath: RootJsonPath,
+ DataType: dataType,
+ Qos: qos,
+ RetainSeed: ReadBoolOrDefault(root, "retainSeed", defaultValue: true),
+ DataTypeAuthored: dataTypeAuthored,
+ GroupId: groupId,
+ EdgeNodeId: edgeNodeId,
+ DeviceId: deviceId,
+ MetricName: metricName);
return true;
}
catch (JsonException) { return false; }
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 ebb6a486..1733fefc 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs
@@ -237,10 +237,17 @@ public sealed class MqttConnectRejectedException : Exception
/// socket no later dispose could ever reach. Now the losing side of that race disposes the
/// client it created and reports .
///
-/// Sparkplug rebirth-on-reconnect (Task 21, which hangs off ) is
-/// deliberately not implemented here.
+///
+/// The publish leg is deliberately narrow. exists to satisfy
+/// — whose entire production caller is
+/// — not to make this a general MQTT publisher. This
+/// driver has no IWritable leg in v1, so a Sparkplug rebirth NCMD is the only
+/// outbound application message it ever sends. Sparkplug rebirth-on-reconnect itself is decided
+/// by hanging off ; this type
+/// only carries the bytes.
+///
///
-public sealed class MqttConnection : IAsyncDisposable, IMqttSubscribeTransport
+public sealed class MqttConnection : IAsyncDisposable, IMqttSubscribeTransport, Sparkplug.IMqttPublishTransport
{
private readonly string _driverId;
@@ -994,6 +1001,86 @@ public sealed class MqttConnection : IAsyncDisposable, IMqttSubscribeTransport
return outcomes;
}
+ ///
+ /// Publishes one application message, bounded by
+ /// so a broker that accepts PUBLISH and
+ /// never completes it cannot park the caller — the same frozen-peer rule every other wait here
+ /// follows.
+ ///
+ ///
+ ///
+ /// A refused PUBLISH throws. MQTTnet reports a broker rejection as a
+ /// result (as it does for CONNACK — see ),
+ /// so a caller that treated "did not throw" as "published" would silently drop a rebirth
+ /// NCMD against a broker whose ACL denies the NCMD topic and then wait forever for the
+ /// birth it never asked for.
+ ///
+ ///
+ /// Publishing on a down session is refused up front rather than left to MQTTnet's
+ /// own exception, so the message names the driver and the broker. A rebirth request lost to
+ /// a reconnect is not a fault: the reconnect path re-requests one itself.
+ ///
+ ///
+ /// The concrete topic to publish on.
+ /// The message body.
+ /// Requested QoS, 0–2; out-of-range values clamp.
+ /// The MQTT retain flag.
+ /// Caller cancellation; linked with the publish deadline.
+ /// A task that completes when the broker has accepted the message.
+ /// The publish deadline elapsed.
+ /// was cancelled.
+ /// The connection was disposed.
+ /// There is no established session, or the broker refused the message.
+ public async Task PublishAsync(
+ string topic,
+ byte[] payload,
+ int qos,
+ bool retain,
+ CancellationToken cancellationToken)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(topic);
+ ArgumentNullException.ThrowIfNull(payload);
+ ObjectDisposedException.ThrowIf(Disposed, this);
+
+ var client = _client;
+ if (client is null || !client.IsConnected)
+ {
+ throw new InvalidOperationException(
+ $"MQTT driver '{_driverId}': cannot publish '{topic}' — there is no established session to "
+ + $"{_options.Host}:{_options.Port}.");
+ }
+
+ var message = new MqttApplicationMessageBuilder()
+ .WithTopic(topic)
+ .WithPayload(payload)
+ .WithQualityOfServiceLevel(MapQos(qos))
+ .WithRetainFlag(retain)
+ .Build();
+
+ using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(_options.ConnectTimeoutSeconds));
+ using var linked = CancellationTokenSource.CreateLinkedTokenSource(
+ cancellationToken,
+ deadline.Token,
+ _lifetimeCts.Token);
+
+ MqttClientPublishResult result;
+ try
+ {
+ result = await client.PublishAsync(message, linked.Token).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ throw Classify(ex, cancellationToken, deadline, operation: "publish");
+ }
+
+ if (result is not null && !result.IsSuccess)
+ {
+ throw new InvalidOperationException(
+ $"MQTT driver '{_driverId}': broker at {_options.Host}:{_options.Port} refused PUBLISH "
+ + $"'{topic}': {result.ReasonCode}.");
+ }
+ }
+
/// Maps a configured QoS integer onto MQTTnet's enum, clamping an out-of-range value.
private static MqttQualityOfServiceLevel MapQos(int qos) => qos switch
{
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 da500db5..cd495944 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs
@@ -1,6 +1,7 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
@@ -22,11 +23,21 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
/// synchronously, so re-running discovery on a timer can only produce the same tree.
///
///
-/// P1 scope — no Sparkplug. is implemented but
-/// never fires in : the
-/// authored set only changes by redeploy. Sparkplug B flips the policy to
-/// and raises the event on DBIRTH — that
-/// is a later task, and the seam here () exists for it.
+/// Two ingest paths, one driver. routes through
+/// (authored topics);
+/// routes through (one group-wide filter, birth certificates,
+/// alias resolution, seq gaps, rebirth NCMDs). Exactly one is live, selected by
+/// and rebuilt whenever
+/// changes anything either captures. They share the
+/// driver-owned , so IReadable reads one store either way.
+///
+///
+/// still never fires. Sparkplug B is the mode that
+/// eventually raises it — a DBIRTH can introduce metrics the previous birth did not carry —
+/// but the policy flip to and the wiring
+/// from to
+/// are Task 22. The seams exist on both sides; nothing
+/// connects them yet.
///
///
/// Composition order is load-bearing.
@@ -83,6 +94,14 @@ public sealed class MqttDriver
private MqttDriverOptions _options;
private MqttSubscriptionManager _subscriptions;
+ ///
+ /// The Sparkplug B ingest path, or in .
+ /// Exactly one of this and is fed authored tags and attached to the
+ /// connection; the other exists but is never registered against, so it can neither route a
+ /// message nor answer a resolve.
+ ///
+ private SparkplugIngestor? _sparkplug;
+
///
/// RawPaths of the currently registered authored tags, in authoring order — the discovery
/// enumeration set. The manager resolves a RawPath to its definition but does not enumerate,
@@ -115,6 +134,7 @@ public sealed class MqttDriver
_driverInstanceId = driverInstanceId;
_logger = logger;
_subscriptions = CreateSubscriptionManager(options);
+ _sparkplug = CreateSparkplugIngestor(options);
RegisterAuthoredTags(options);
}
@@ -141,6 +161,17 @@ public sealed class MqttDriver
///
internal MqttSubscriptionManager Subscriptions => _subscriptions;
+ ///
+ /// The Sparkplug B ingest path, or outside
+ /// . Internal: the shell tests drive
+ /// to simulate an edge node without a broker, and
+ /// Tasks 22/23 read its .
+ ///
+ internal SparkplugIngestor? Sparkplug => _sparkplug;
+
+ /// Whether this driver instance ingests Sparkplug B rather than plain topics.
+ private bool IsSparkplug => _options.Mode == MqttMode.SparkplugB;
+
///
/// Test seam: awaited inside while the lifecycle gate is
/// held, immediately before the broker connect. Lets a test park a lifecycle operation
@@ -360,7 +391,7 @@ public sealed class MqttDriver
var folder = builder.Folder("Mqtt", "Mqtt");
foreach (var rawPath in _authoredRawPaths)
{
- if (!_subscriptions.TryResolve(rawPath, out var def))
+ if (!TryResolve(rawPath, out var def))
{
continue;
}
@@ -388,11 +419,24 @@ public sealed class MqttDriver
IReadOnlyList fullReferences,
TimeSpan publishingInterval,
CancellationToken cancellationToken)
- => _subscriptions.SubscribeAsync(fullReferences, publishingInterval, cancellationToken);
+ => _sparkplug is { } sparkplug
+ ? sparkplug.SubscribeAsync(fullReferences, publishingInterval, cancellationToken)
+ : _subscriptions.SubscribeAsync(fullReferences, publishingInterval, cancellationToken);
///
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
- => _subscriptions.UnsubscribeAsync(handle, cancellationToken);
+ => _sparkplug is { } sparkplug
+ ? sparkplug.UnsubscribeAsync(handle, cancellationToken)
+ : _subscriptions.UnsubscribeAsync(handle, cancellationToken);
+
+ /// Resolves a RawPath through whichever ingest path this driver's mode selected.
+ /// The driver wire reference.
+ /// The definition when this returns .
+ /// when the reference is an authored tag of the live ingest path.
+ private bool TryResolve(string rawPath, out MqttTagDefinition def)
+ => _sparkplug is { } sparkplug
+ ? sparkplug.TryResolve(rawPath, out def)
+ : _subscriptions.TryResolve(rawPath, out def);
// ---- IReadable ----
@@ -417,7 +461,9 @@ public sealed class MqttDriver
var results = new DataValueSnapshot[fullReferences.Count];
for (var i = 0; i < fullReferences.Count; i++)
{
- results[i] = _subscriptions.Values.Read(fullReferences[i]);
+ // The driver-owned cache, not the live ingest path's: both are handed this same instance,
+ // and reading it directly means a mid-flight ingest rebuild can never blank a read.
+ results[i] = _values.Read(fullReferences[i]);
}
return Task.FromResult>(results);
@@ -539,9 +585,16 @@ public sealed class MqttDriver
var connection = new MqttConnection(_options, _driverInstanceId, _logger);
// MUST precede the connect: this wires message delivery AND the reconnect re-subscribe.
- // The manager's Reconnected handler is passed through unwrapped on purpose — its
- // throw-on-total-failure is what tears a deaf session down and retries it.
- _subscriptions.AttachTo(connection);
+ // The handler is passed through unwrapped on purpose — its throw-on-total-failure is what
+ // tears a deaf session down and retries it.
+ if (_sparkplug is { } attaching)
+ {
+ attaching.AttachTo(connection);
+ }
+ else
+ {
+ _subscriptions.AttachTo(connection);
+ }
if (BeforeConnectHookForTests is { } hook)
{
@@ -561,6 +614,29 @@ public sealed class MqttDriver
throw new ObjectDisposedException(nameof(MqttDriver));
}
+ // Sparkplug subscribes ONCE, here, rather than on the OPC UA server's first subscribe: the
+ // driver must see birth certificates whether or not anything is subscribed yet, because a
+ // birth is where every alias and datatype comes from. This also issues the late-join
+ // rebirth (§3.6 #5) — the initial connect does not fire Reconnected.
+ //
+ // It runs BEFORE `_connection` is published, and disposes the session it was given if it
+ // fails. Publishing first would leave a live, supervised connection behind a throw: the
+ // driver-host resilience layer re-runs this method, the retry overwrites `_connection`, and
+ // the first session becomes an orphan no later dispose can reach — the exact leak
+ // MqttConnection's own remarks describe having already fixed once.
+ if (_sparkplug is { } sparkplug)
+ {
+ try
+ {
+ await sparkplug.EstablishAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch
+ {
+ await connection.DisposeAsync().ConfigureAwait(false);
+ throw;
+ }
+ }
+
_connection = connection;
WriteHealth(new DriverHealth(DriverState.Healthy, connection.LastMessageUtc, null));
@@ -623,6 +699,7 @@ public sealed class MqttDriver
if (!SameIngest(next, _options))
{
_subscriptions = CreateSubscriptionManager(next);
+ _sparkplug = CreateSparkplugIngestor(next);
}
_options = next;
@@ -645,10 +722,59 @@ public sealed class MqttDriver
return manager;
}
+ ///
+ /// Builds the Sparkplug B ingest path for , or
+ /// outside . Shares the driver-owned
+ /// for the same reason the plain manager does: a rebuild for
+ /// changed settings must not blank every observed value.
+ ///
+ private SparkplugIngestor? CreateSparkplugIngestor(MqttDriverOptions options)
+ {
+ if (options.Mode != MqttMode.SparkplugB)
+ {
+ return null;
+ }
+
+ if (options.Sparkplug is { ActAsPrimaryHost: true })
+ {
+ // 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
+ // MqttConnection.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);
+ }
+
+ var ingestor = new SparkplugIngestor(
+ options,
+ _driverInstanceId,
+ subscribeTransport: null,
+ publishTransport: null,
+ logger: _logger,
+ cache: _values,
+ maxPayloadBytes: options.MaxPayloadBytes);
+
+ // The published reference is the RawPath the ingestor already stamped on the args; the driver
+ // only re-raises with itself as sender.
+ ingestor.OnDataChange += (_, args) => OnDataChange?.Invoke(this, args);
+ return ingestor;
+ }
+
/// Registers the authored raw tags wholesale and refreshes the discovery enumeration set.
private void RegisterAuthoredTags(MqttDriverOptions options)
{
- var mapped = _subscriptions.Register(options.RawTags);
+ // Wholesale into the LIVE path only. Feeding Sparkplug tags to the plain parser would log a
+ // "did not map" warning for every one of them (they carry no topic) and vice versa.
+ var mapped = _sparkplug is { } sparkplug
+ ? sparkplug.Register(options.RawTags)
+ : _subscriptions.Register(options.RawTags);
// Enumerate in authoring order; the manager owns which of these actually mapped, and
// DiscoverAsync skips the rest.
@@ -676,16 +802,19 @@ public sealed class MqttDriver
private static bool SameSession(MqttDriverOptions a, MqttDriverOptions b)
=> SessionIdentity(a).Equals(SessionIdentity(b));
- /// Whether two option sets produce an identical .
+ ///
+ /// Whether two option sets produce an identical ingest path —
+ /// in Plain mode, in Sparkplug B.
+ ///
///
- /// ⚠️ P2 (Sparkplug, tasks 21/22) must extend . It names
- /// only the settings the P1 manager captures at construction — Mode,
- /// Plain.DefaultQos, MaxPayloadBytes — and deliberately no
- /// field, because nothing reads one yet. The moment a
- /// Sparkplug handler captures its own options (group id, host id, rebirth policy, birth
- /// window) at construction, a delta changing one of them would be judged "same ingest",
- /// applied in place, and silently never reach the component that needed rebuilding — the
- /// driver would keep subscribing to the OLD group id and report perfect health.
+ /// ⚠️ Every setting an ingest path captures at construction MUST be named by
+ /// . A setting that is captured but unnamed makes a delta
+ /// changing it compare "same ingest", get applied in place, and silently never reach the
+ /// component that needed rebuilding — the driver would keep subscribing to the OLD Sparkplug
+ /// group id and report perfect health. Task 21 closed the Sparkplug half of that gap by naming
+ /// the whole record (it has value equality, so a member
+ /// added there is covered automatically — which is the point of naming the record rather than
+ /// enumerating its fields).
///
private static bool SameIngest(MqttDriverOptions a, MqttDriverOptions b)
=> IngestIdentity(a).Equals(IngestIdentity(b));
@@ -714,12 +843,18 @@ public sealed class MqttDriver
Ingest = IngestIdentity(o),
};
- /// The settings captures at construction.
+ /// The settings the live ingest path captures at construction.
+ ///
+ /// is named as a whole record rather than field-by-field: it
+ /// has structural value equality, so a member added to it in a later task is covered here with
+ /// no edit — and the failure mode of forgetting one is silent (see ).
+ ///
private static object IngestIdentity(MqttDriverOptions o) => new
{
o.Mode,
DefaultQos = o.Plain?.DefaultQos,
o.MaxPayloadBytes,
+ o.Sparkplug,
};
/// Shared teardown for , and the session rebuild.
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
new file mode 100644
index 00000000..0133813d
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SparkplugIngestor.cs
@@ -0,0 +1,1775 @@
+using System.Collections.Concurrent;
+using System.Collections.Frozen;
+using System.Globalization;
+using System.Text;
+using System.Text.Json;
+using Microsoft.Extensions.Logging;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
+
+///
+/// The Sparkplug B ingest state machine — design doc §3.6, the driver's correctness core. It owns
+/// the authored (group, node, device?, metric) → RawPath index, the live
+/// , one per edge node, and the rebirth
+/// policy; it turns every decoded NBIRTH/DBIRTH/NDATA/DDATA/NDEATH/DDEATH/STATE into
+/// notifications plus updates, or into a
+/// bounded, debounced rebirth NCMD.
+///
+///
+///
+/// The published reference is the RawPath. Every this
+/// type raises carries — the tag's RawPath — never a
+/// composed Sparkplug reference such as Plant1/EdgeA/Filler1:Temperature and never the
+/// topic. DriverHostActor's dual-namespace fan-out is keyed by RawPath, so publishing
+/// under a Sparkplug-shaped key produces a driver that satisfies a naive unit test and delivers
+/// nothing at all in production. The Sparkplug tuple is the lookup; the RawPath is the
+/// identity.
+///
+///
+/// Bind by metric NAME, resolve by alias (§3.6 #1). A DATA metric carries only an alias;
+/// the alias is resolved through the scope's — rebuilt wholesale by
+/// every birth — back to the stable metric name, and that name is what the authored index
+/// is keyed by. Binding a tag to an alias silently mis-routes Pressure into a Temperature tag
+/// the first time an edge node reuses an alias across a rebirth: good quality, plausible values,
+/// completely wrong.
+///
+///
+/// Every signed integer goes through .
+/// Sparkplug carries Int8/16/32/64 as two's complement in an unsigned proto field, so a
+/// metric whose value is -42 arrives as 4294967254. Skipping the call publishes a plausible,
+/// Good-quality, completely wrong number.
+///
+///
+/// An invalid payload never mutates state. An undecodable body advances no sequence
+/// tracker, clears no alias table and evicts no birth — including for an NDEATH. Garbage bytes
+/// are not evidence of anything, and a hostile or merely broken publisher on a group-wide
+/// # subscription must not be able to blank the address space with them.
+///
+///
+/// NDEATH is not part of the sequence. It is the broker-published Last Will and carries
+/// no seq; it is tied to its birth by
+/// instead. Feeding it to reports a gap on every death and
+/// answers a node that has just died with a rebirth request it can never serve.
+///
+///
+/// Rebirth requests are debounced per edge node. A single lost message on a busy node
+/// produces one unknown-alias or data-before-birth event per metric per message until the birth
+/// lands; without a floor between requests that is the NCMD storm the design warns about. See
+/// .
+///
+///
+/// Unauthored edge nodes are ignored outright. The driver subscribes
+/// spBv1.0/{group}/#, so a large plant delivers traffic for every node in the group. Only
+/// messages whose (group, edgeNode) appears in the authored index are processed at all —
+/// which is what bounds the sequence-tracker and birth-cache maps by configuration rather
+/// than by whatever the plant happens to publish. Device-level filtering happens later, at the
+/// metric-resolution step, because an unauthored device's DDATA still advances its edge node's
+/// sequence and dropping it early would manufacture a gap.
+///
+///
+/// Nothing here throws on MQTTnet's dispatcher thread. runs on
+/// it: every failure degrades a tag or drops a message, a throwing
+/// 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.
+///
+///
+public sealed class SparkplugIngestor
+{
+ ///
+ /// Minimum interval between two rebirth NCMDs aimed at the same edge node, when the caller does
+ /// not supply one. Sized against the observation that a rebirth is a full metadata republish an
+ /// edge node needs a moment to assemble: repeatedly commanding a node that is already
+ /// republishing is the storm, not the cure.
+ ///
+ public static readonly TimeSpan DefaultRebirthDebounce = TimeSpan.FromSeconds(10);
+
+ // OPC UA status codes (values verified against Opc.Ua.StatusCodes, not recalled).
+ private const uint StatusGood = 0x00000000u;
+
+ ///
+ /// The source has announced it is offline (NDEATH/DDEATH), or a birth voided the scope that was
+ /// feeding the tag. BadCommunicationError rather than the arguably more literal
+ /// BadNoCommunication (0x80310000) because it is what this repo's drivers actually
+ /// produce for a link that has stopped delivering — Modbus, S7, TwinCAT, FOCAS,
+ /// OpcUaClient and this driver's own plain-mode path all emit it, while no driver emits
+ /// BadNoCommunication at all (it appears once, in a CLI formatter's label map). Following the
+ /// precedent keeps one code meaning "not communicating" across the whole fleet.
+ ///
+ private const uint StatusBadCommunicationError = 0x80050000u;
+
+ /// The metric's value does not fit the tag's effective data type — including an explicit Sparkplug null.
+ private const uint StatusBadTypeMismatch = 0x80740000u;
+
+ /// The subscribed reference is not an authored Sparkplug tag.
+ private const uint StatusBadNodeIdUnknown = 0x80340000u;
+
+ private readonly string _driverId;
+ private readonly ILogger? _logger;
+ private readonly TimeSpan _rebirthDebounce;
+ private readonly Func _utcNow;
+
+ /// RawPath → the handle of the subscription currently covering it.
+ private readonly ConcurrentDictionary _handleByRawPath = new(StringComparer.Ordinal);
+
+ /// Handle → the references it was created for, so unsubscribe can undo exactly its own.
+ private readonly ConcurrentDictionary _refsByHandle = new();
+
+ /// One stream-continuity tracker per authored edge node; bounded by the authored set.
+ private readonly ConcurrentDictionary _trackers = new();
+
+ /// Last rebirth-NCMD instant per edge node, in UTC ticks — the debounce floor.
+ private readonly ConcurrentDictionary _lastRebirthTicks = new();
+
+ /// Keys whose anomaly has already been logged loudly once, cleared by .
+ private readonly ConcurrentDictionary _warned = new(StringComparer.Ordinal);
+
+ /// In-flight rebirth publishes, so teardown and tests can drain rather than race them.
+ private readonly List _pendingRebirths = [];
+
+ private readonly EquipmentTagRefResolver _resolver;
+
+ private long _handleSeq;
+
+ /// The authored table + its Sparkplug indexes, swapped atomically by .
+ private volatile AuthoredTable _authored = AuthoredTable.Empty;
+
+ private readonly MqttDriverOptions _options;
+ private volatile SparkplugHostState? _hostState;
+ private IMqttSubscribeTransport? _subscribeTransport;
+ private IMqttPublishTransport? _publishTransport;
+
+ /// Initializes a new instance of the class.
+ ///
+ /// Driver configuration. supplies the group id, the
+ /// host id and the rebirth policy; a section is treated as the defaults.
+ ///
+ /// Driver instance id, used only for log correlation.
+ ///
+ /// The SUBSCRIBE seam. records the group filter without dialling a broker
+ /// (the shape tests use); supplies the live connection.
+ ///
+ ///
+ /// The NCMD publish seam. disables rebirth requests, which are then
+ /// logged and skipped rather than throwing on the dispatcher thread.
+ ///
+ /// Optional logger; never receives credentials or payload bodies.
+ /// The last-value store backing IReadable; defaults to a fresh one.
+ ///
+ /// Ceiling on an inbound message body. Non-positive falls back to
+ /// — "unbounded" is not offered,
+ /// because the bound protects a shared dispatcher thread.
+ ///
+ ///
+ /// Minimum interval between rebirth NCMDs aimed at one edge node. Defaults to
+ /// ; disables the floor (tests).
+ ///
+ /// Clock seam for the debounce; defaults to .
+ public SparkplugIngestor(
+ MqttDriverOptions options,
+ string driverId,
+ IMqttSubscribeTransport? subscribeTransport = null,
+ IMqttPublishTransport? publishTransport = null,
+ ILogger? logger = null,
+ LastValueCache? cache = null,
+ int maxPayloadBytes = MqttSubscriptionManager.DefaultMaxPayloadBytes,
+ TimeSpan? rebirthDebounce = null,
+ Func? utcNow = null)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ ArgumentException.ThrowIfNullOrWhiteSpace(driverId);
+
+ _options = options;
+ _driverId = driverId;
+ _subscribeTransport = subscribeTransport;
+ _publishTransport = publishTransport;
+ _logger = logger;
+ Values = cache ?? new LastValueCache();
+ MaxPayloadBytes = maxPayloadBytes > 0 ? maxPayloadBytes : MqttSubscriptionManager.DefaultMaxPayloadBytes;
+ _rebirthDebounce = rebirthDebounce is { } d && d >= TimeSpan.Zero ? d : DefaultRebirthDebounce;
+ _utcNow = utcNow ?? (() => DateTime.UtcNow);
+ _resolver = new EquipmentTagRefResolver(
+ rawPath => _authored.ByRawPath.TryGetValue(rawPath, out var def) ? def : null);
+ }
+
+ ///
+ public event EventHandler? OnDataChange;
+
+ ///
+ /// Raised after a birth certificate has been applied — the seam Task 22 wires to
+ /// IRediscoverable.OnRediscoveryNeeded so a DBIRTH introducing metrics the previous birth
+ /// did not carry rebuilds the address space. Deliberately not wired here: this type detects, the
+ /// driver decides.
+ ///
+ public event EventHandler? BirthObserved;
+
+ /// Raised when a primary-host STATE message is observed. Informational; gates nothing.
+ public event EventHandler? HostStateObserved;
+
+ /// The last observed value per RawPath — the push→poll bridge IReadable serves from.
+ public LastValueCache Values { get; }
+
+ /// The live birth state: one per observed scope. Read by Tasks 22/23.
+ public BirthCache Births { get; } = new();
+
+ /// Ceiling on an inbound message body; a larger message is dropped before any decode.
+ public int MaxPayloadBytes { get; }
+
+ ///
+ /// The last observed primary-host STATE, or when none has been seen.
+ ///
+ public SparkplugHostState? HostState => _hostState;
+
+ /// The group-wide filter this ingestor subscribes, or when no group is configured.
+ public string? GroupFilter
+ {
+ get
+ {
+ var group = _options.Sparkplug?.GroupId;
+ return string.IsNullOrWhiteSpace(group) ? null : $"spBv1.0/{group}/#";
+ }
+ }
+
+ ///
+ /// The primary-host STATE filter this ingestor subscribes, or when no host
+ /// id is configured. Deliberately narrowed to the configured host id rather than
+ /// spBv1.0/STATE/#: this driver has no use for a stranger's host state, and a wildcard
+ /// would grow an unbounded observation map off traffic nobody asked for.
+ ///
+ public string? StateFilter
+ {
+ get
+ {
+ var hostId = _options.Sparkplug?.HostId;
+ return string.IsNullOrWhiteSpace(hostId) ? null : SparkplugTopic.FormatState(hostId);
+ }
+ }
+
+ ///
+ /// Wires this ingestor onto a live connection: inbound messages route to
+ /// , the connection becomes both transports, and every reconnect runs
+ /// (re-subscribe + birth-state reset + late-join rebirth).
+ ///
+ /// The connection to observe.
+ public void AttachTo(MqttConnection connection)
+ {
+ ArgumentNullException.ThrowIfNull(connection);
+
+ _subscribeTransport = connection;
+ _publishTransport = connection;
+ connection.MessageReceived += HandleMessage;
+ connection.Reconnected += OnReconnectedAsync;
+ }
+
+ ///
+ /// Replaces the authored tag table with the deploy's raw tags, rebuilding the Sparkplug indexes.
+ /// Wholesale, not additive: a redeploy that drops a tag must stop feeding it.
+ ///
+ ///
+ /// A redeploy that ADDS an edge node does not rebirth it here. Until that deploy the node
+ /// was unauthored, so its traffic was filtered out and the driver holds no birth for it. It
+ /// recovers on the node's next DATA message, which lands as data-before-birth and asks for one —
+ /// self-correcting, and deliberately not a fresh NCMD fan-out on every redeploy, which on a large
+ /// plant would make a routine config change a group-wide rebirth storm.
+ ///
+ /// The authored raw tags delivered by the deploy artifact.
+ /// How many entries mapped to a usable Sparkplug definition.
+ public int Register(IEnumerable rawTags)
+ {
+ ArgumentNullException.ThrowIfNull(rawTags);
+
+ var byRawPath = new Dictionary(StringComparer.Ordinal);
+ foreach (var entry in rawTags)
+ {
+ if (MqttTagDefinitionFactory.FromSparkplugTagConfig(entry.TagConfig, entry.RawPath, out var def))
+ {
+ byRawPath[entry.RawPath] = def;
+ }
+ else
+ {
+ _logger?.LogWarning(
+ "MQTT driver '{DriverId}': tag config did not map to a Sparkplug definition "
+ + "(groupId/edgeNodeId/metricName are all required); skipping. RawPath={RawPath}",
+ _driverId,
+ entry.RawPath);
+ }
+ }
+
+ var table = AuthoredTable.Build(byRawPath);
+ _authored = table;
+ _warned.Clear();
+
+ // Drop state belonging to edge nodes this deploy removed. Without this a tracker (and its
+ // rebirth debounce slot) for a node nobody reads survives every future redeploy.
+ foreach (var key in _trackers.Keys)
+ {
+ if (!table.EdgeNodes.Contains(key))
+ {
+ _trackers.TryRemove(key, out _);
+ _lastRebirthTicks.TryRemove(key, out _);
+ }
+ }
+
+ foreach (var rawPath in _handleByRawPath.Keys)
+ {
+ if (!table.ByRawPath.ContainsKey(rawPath))
+ {
+ _handleByRawPath.TryRemove(rawPath, out _);
+ }
+ }
+
+ return byRawPath.Count;
+ }
+
+ /// Resolves a RawPath to its authored definition through the shared v3 resolver.
+ /// The driver wire reference.
+ /// The definition, when this returns .
+ /// when the reference is an authored Sparkplug tag.
+ public bool TryResolve(string rawPath, out MqttTagDefinition def) => _resolver.TryResolve(rawPath, out def!);
+
+ /// The authored RawPaths, in no particular order — the discovery enumeration source.
+ public IReadOnlyCollection AuthoredRawPaths => _authored.ByRawPath.Keys;
+
+ ///
+ /// Establishes the group-wide (and, when configured, STATE) subscription and requests a
+ /// late-join rebirth from every authored edge node.
+ ///
+ ///
+ ///
+ /// Unlike plain mode, the subscribe is NOT driven by . A
+ /// Sparkplug driver must see birth certificates whether or not the OPC UA server has
+ /// subscribed anything — the birth is where datatypes and aliases come from, and Task 22's
+ /// discovery depends on having seen one. So the driver establishes the filter once, at
+ /// connect, and only records which references may be published.
+ ///
+ ///
+ /// Total failure throws. There is exactly one filter and it covers every tag, so a
+ /// broker that refuses it leaves the whole driver deaf — the one outcome that must be a
+ /// visible fault (the driver-host resilience layer re-runs initialize under backoff) rather
+ /// than a healthy-looking driver receiving nothing. This is the same rule
+ /// applies, stated once.
+ ///
+ ///
+ /// Cancellation for the SUBSCRIBE round-trip.
+ /// A task that completes when the subscribe has been answered.
+ /// No group id is configured, or every filter was refused.
+ public async Task EstablishAsync(CancellationToken cancellationToken)
+ {
+ await SubscribeGroupAsync(cancellationToken).ConfigureAwait(false);
+ RequestLateJoinRebirths("initial connect");
+ }
+
+ ///
+ /// Records a batch of RawPath references against a new handle. Issues no SUBSCRIBE — the
+ /// group-wide filter is established once by ; see its remarks.
+ ///
+ /// The RawPath references to subscribe.
+ /// Ignored — Sparkplug is push-based.
+ /// Unused; this call touches no network.
+ /// The handle every resulting is attributed to.
+ public Task SubscribeAsync(
+ IReadOnlyList fullReferences,
+ TimeSpan publishingInterval,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(fullReferences);
+
+ var defs = new List(fullReferences.Count);
+ var now = _utcNow();
+ foreach (var reference in fullReferences)
+ {
+ if (_resolver.TryResolve(reference, out var def))
+ {
+ defs.Add(def);
+ continue;
+ }
+
+ // Not an authored Sparkplug tag. Say so rather than leaving it in "waiting for initial
+ // data" forever — a reference that will never be fed is a different fault from one that
+ // has simply not been fed yet.
+ _logger?.LogWarning(
+ "MQTT driver '{DriverId}': subscribe reference '{RawPath}' is not an authored Sparkplug tag.",
+ _driverId,
+ reference);
+ Values.Update(reference, new DataValueSnapshot(null, StatusBadNodeIdUnknown, null, now));
+ }
+
+ var handle = new SparkplugSubscriptionHandle(
+ $"sparkplug:{Interlocked.Increment(ref _handleSeq)}:[{defs.Count} ref(s)]");
+ _refsByHandle[handle] = [.. defs.Select(d => d.Name)];
+ foreach (var def in defs)
+ {
+ _handleByRawPath[def.Name] = handle;
+ }
+
+ return Task.FromResult(handle);
+ }
+
+ ///
+ /// Stops attributing notifications to . References another live
+ /// subscription also covers keep publishing; the broker-side group filter is untouched.
+ ///
+ /// The handle returned by .
+ /// Unused; present for the ISubscribable shape.
+ /// A completed task.
+ public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
+ {
+ if (handle is not null && _refsByHandle.TryRemove(handle, out var refs))
+ {
+ foreach (var rawPath in refs)
+ {
+ // Only clear the mapping if it is still OURS — a later overlapping subscription may
+ // have taken the reference over, and it must keep publishing.
+ _handleByRawPath.TryRemove(new KeyValuePair(rawPath, handle));
+ }
+ }
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Recovers the ingest state after a reconnect: drops every birth, resets every sequence
+ /// tracker, re-establishes the group filter and requests a late-join rebirth from every authored
+ /// edge node (§3.6 invariant #5).
+ ///
+ ///
+ ///
+ /// Clearing the birth cache is not housekeeping — it is the correctness step. Across
+ /// an outage of unknown length an edge node may have restarted and rebound its aliases; a
+ /// DDATA resolved against the pre-outage table would route a value to whichever tag used to
+ /// own that alias. Same for the sequence baseline: carried across, a stream could look
+ /// contiguous purely because the node published the right number of messages while the
+ /// driver was away.
+ ///
+ ///
+ /// Values are deliberately not staled here. Reconnect is a driver-side event, not a
+ /// statement about the plant; the last observed values remain the best available answer
+ /// until the rebirth lands, and the connection's own health surface already reports
+ /// Reconnecting. Only a death — the source saying it is offline — stales a tag.
+ ///
+ ///
+ /// The connection's lifetime token; cancelled by its dispose.
+ /// A task that completes when the re-subscribe has been answered.
+ /// Every filter was refused — the caller tears the session down.
+ public async Task OnReconnectedAsync(CancellationToken cancellationToken)
+ {
+ Births.Clear();
+ _trackers.Clear();
+
+ await SubscribeGroupAsync(cancellationToken).ConfigureAwait(false);
+ RequestLateJoinRebirths("reconnect");
+ }
+
+ ///
+ /// Routes one inbound MQTT message: parses the topic, decodes the Sparkplug payload and hands it
+ /// to . Runs on MQTTnet's dispatcher thread and never throws.
+ ///
+ /// The concrete topic the message arrived on.
+ /// The message body; valid only for the duration of this call.
+ ///
+ /// The message's retain flag. Not filtered on: a retained NBIRTH/STATE is exactly the
+ /// late-join metadata this driver wants, and Sparkplug DATA is never published retained.
+ ///
+ public void HandleMessage(string topic, ReadOnlySpan payload, bool retained)
+ {
+ try
+ {
+ if (!SparkplugTopic.TryParse(topic, out var parsed))
+ {
+ // A group-wide '#' subscription legitimately delivers topics this driver has no opinion
+ // about. Debug, not warn — an operator must not have to read one line per stray message.
+ _logger?.LogDebug(
+ "MQTT driver '{DriverId}': '{Topic}' is not a Sparkplug topic; ignored.",
+ _driverId,
+ topic);
+ return;
+ }
+
+ if (payload.Length > MaxPayloadBytes)
+ {
+ // Refused BEFORE any decode: the whole point of the bound is that one publisher cannot
+ // impose an unbounded protobuf parse on the shared dispatcher thread. Nothing mutates.
+ WarnOnce(
+ $"oversize:{topic}",
+ "MQTT driver '{DriverId}': '{Topic}' carried {Bytes} bytes, over the {Max}-byte ceiling; dropped.",
+ _driverId,
+ topic,
+ payload.Length,
+ MaxPayloadBytes);
+ return;
+ }
+
+ if (parsed.Type == SparkplugMessageType.STATE)
+ {
+ HandleState(parsed, payload);
+ return;
+ }
+
+ Dispatch(parsed, SparkplugCodec.Decode(payload));
+ }
+ catch (Exception ex)
+ {
+ // Defence in depth: every path below is written not to throw, but an escape here surfaces
+ // inside MQTTnet's own pump and stalls delivery for every subscription on the connection.
+ _logger?.LogError(
+ ex,
+ "MQTT driver '{DriverId}': Sparkplug ingest threw for '{Topic}'; the message was dropped.",
+ _driverId,
+ topic);
+ }
+ }
+
+ ///
+ /// Routes one decoded Sparkplug message — the state machine proper, and the seam the
+ /// §3.6 matrix is driven through without a broker.
+ ///
+ /// The parsed topic.
+ /// The decoded payload. Checked for validity here, not by the caller.
+ public void Dispatch(SparkplugTopic topic, SparkplugPayload payload)
+ {
+ ArgumentNullException.ThrowIfNull(topic);
+ ArgumentNullException.ThrowIfNull(payload);
+
+ if (topic.Type == SparkplugMessageType.STATE)
+ {
+ return; // STATE carries no Sparkplug payload; HandleMessage routes it separately.
+ }
+
+ // NCMD/DCMD flow host → node. Our own rebirth requests come back on the '#' subscription;
+ // treating them as ingest would be, at best, noise.
+ if (topic.Type is SparkplugMessageType.NCMD or SparkplugMessageType.DCMD)
+ {
+ return;
+ }
+
+ if (topic.GroupId is not { } groupId || topic.EdgeNodeId is not { } edgeNodeId)
+ {
+ return; // TryParse guarantees both for a non-STATE topic; belt and braces.
+ }
+
+ var node = new SparkplugNodeKey(groupId, edgeNodeId);
+ if (!_authored.EdgeNodes.Contains(node))
+ {
+ _logger?.LogDebug(
+ "MQTT driver '{DriverId}': {Type} for unauthored edge node '{Node}'; ignored.",
+ _driverId,
+ topic.Type,
+ node);
+ return;
+ }
+
+ if (!payload.IsValid)
+ {
+ // NOT a rebirth trigger and NOT a state mutation — see the type remarks. An undecodable
+ // body is not evidence that anything about the node's stream has changed.
+ WarnOnce(
+ $"invalid:{node}",
+ "MQTT driver '{DriverId}': {Type} from '{Node}' could not be decoded as a Sparkplug payload; dropped.",
+ _driverId,
+ topic.Type,
+ node);
+ return;
+ }
+
+ switch (topic.Type)
+ {
+ case SparkplugMessageType.NBIRTH:
+ OnNodeBirth(node, payload);
+ break;
+
+ case SparkplugMessageType.DBIRTH when topic.DeviceId is { } dbirthDevice:
+ OnDeviceBirth(node, dbirthDevice, payload);
+ break;
+
+ case SparkplugMessageType.NDATA:
+ OnData(node, new SparkplugScope(groupId, edgeNodeId, null), payload);
+ break;
+
+ case SparkplugMessageType.DDATA when topic.DeviceId is { } ddataDevice:
+ OnData(node, new SparkplugScope(groupId, edgeNodeId, ddataDevice), payload);
+ break;
+
+ case SparkplugMessageType.NDEATH:
+ OnNodeDeath(node, payload);
+ break;
+
+ case SparkplugMessageType.DDEATH when topic.DeviceId is { } ddeathDevice:
+ OnDeviceDeath(node, ddeathDevice, payload);
+ break;
+
+ default:
+ break;
+ }
+ }
+
+ ///
+ /// Awaits every rebirth publish this ingestor has dispatched and not yet observed. Test seam and
+ /// teardown helper — the publishes themselves are fire-and-forget by design, because a rebirth
+ /// NCMD must never be able to park MQTTnet's dispatcher thread.
+ ///
+ /// A task that completes when no dispatched rebirth publish is outstanding.
+ internal async Task DrainPendingRebirthsAsync()
+ {
+ Task[] pending;
+ lock (_pendingRebirths)
+ {
+ pending = [.. _pendingRebirths];
+ }
+
+ foreach (var task in pending)
+ {
+ try
+ {
+ await task.ConfigureAwait(false);
+ }
+ catch (Exception)
+ {
+ // Already logged where it was dispatched; a failed rebirth is not a caller's problem.
+ }
+ }
+ }
+
+ // ---- births ----
+
+ ///
+ /// Applies an NBIRTH: restarts the node's sequence at the birth's seq, adopts its
+ /// bdSeq session token, rebuilds the node's catalog, stales every device the birth
+ /// invalidated, and publishes the birth's own values.
+ ///
+ private void OnNodeBirth(SparkplugNodeKey node, SparkplugPayload payload)
+ {
+ // AcceptNodeBirth, never Accept: an NBIRTH restarts the sequence by definition, so running it
+ // through the gap detector would flag a gap on the very message that resynchronized everything
+ // and answer a birth with a demand for another one.
+ var bdSeq = SequenceTracker.TryReadBdSeq(payload, out var token) ? token : (ulong?)null;
+ if (!TrackerFor(node).AcceptNodeBirth(payload.Seq, bdSeq))
+ {
+ // Deliberately NOT a rebirth trigger: the metadata just arrived, and commanding another
+ // birth from a node whose seq we cannot read would loop against the same malformed payload.
+ WarnOnce(
+ $"birthseq:{node}",
+ "MQTT driver '{DriverId}': NBIRTH from '{Node}' carried no usable seq ({Seq}); "
+ + "gap detection is disabled for this node until its next birth.",
+ _driverId,
+ node,
+ payload.Seq);
+ }
+
+ var outcome = Births.ApplyNodeBirth(node.GroupId, node.EdgeNodeId, payload.Metrics);
+ LogBirthAnomaly(node.ToString(), outcome.Result);
+
+ var sourceUtc = ToUtc(payload.TimestampMs);
+
+ // An NBIRTH voids every prior DBIRTH under the node (Sparkplug spec): those devices MUST
+ // re-DBIRTH, and until they do their aliases are meaningless. Staling them is what stops the
+ // driver serving values resolved against a table it has just thrown away.
+ foreach (var invalidated in outcome.InvalidatedDevices)
+ {
+ FanStale(invalidated.Scope, sourceUtc, "its edge node re-birthed; awaiting a fresh DBIRTH");
+ }
+
+ PublishBirthValues(new SparkplugScope(node.GroupId, node.EdgeNodeId, null), payload, sourceUtc);
+ RaiseBirthObserved(new SparkplugScope(node.GroupId, node.EdgeNodeId, null), payload);
+ }
+
+ /// Applies a DBIRTH: rebuilds exactly that device's catalog and publishes its values.
+ private void OnDeviceBirth(SparkplugNodeKey node, string deviceId, SparkplugPayload payload)
+ {
+ // A DBIRTH is a sequenced member of the edge node's stream — only the NBIRTH restarts it.
+ CheckSequence(node, payload, "DBIRTH");
+
+ var result = Births.ApplyDeviceBirth(node.GroupId, node.EdgeNodeId, deviceId, payload.Metrics);
+ var scope = new SparkplugScope(node.GroupId, node.EdgeNodeId, deviceId);
+ LogBirthAnomaly(scope.ToString(), result);
+
+ var sourceUtc = ToUtc(payload.TimestampMs);
+ PublishBirthValues(scope, payload, sourceUtc);
+ RaiseBirthObserved(scope, payload);
+ }
+
+ // ---- data ----
+
+ ///
+ /// Applies an NDATA/DDATA: checks stream continuity, resolves every metric through the scope's
+ /// birth-built alias table, and publishes under the authored tag's RawPath.
+ ///
+ private void OnData(SparkplugNodeKey node, SparkplugScope scope, SparkplugPayload payload)
+ {
+ CheckSequence(node, payload, scope.IsDevice ? "DDATA" : "NDATA");
+
+ // Scope-level filtering happens HERE, after the sequence check, never before it: an unauthored
+ // device's DDATA is still a member of its edge node's stream, and skipping it earlier would
+ // manufacture a gap on the very next authored message. Past this point there is nothing this
+ // scope could publish — so there is also nothing worth demanding a rebirth for. Without the
+ // guard, a device nobody authored that publishes DDATA without ever DBIRTHing (spec-violating,
+ // but they exist) would drive a permanent NCMD at the debounce cadence, forever.
+ if (!_authored.ByScope.ContainsKey(scope))
+ {
+ return;
+ }
+
+ var table = Births.Find(scope);
+ if (table is null)
+ {
+ // Data before any birth (§3.6 #3): we hold no alias table for this scope, so every metric
+ // in this payload is unroutable. One rebirth request for the whole message, not one per
+ // metric — the debounce would collapse them anyway, but not asking is cheaper than asking
+ // and being suppressed.
+ WarnOnce(
+ $"prebirth:{scope}",
+ "MQTT driver '{DriverId}': data for '{Scope}' arrived before any birth certificate; "
+ + "requesting a rebirth.",
+ _driverId,
+ scope);
+ RequestRebirth(node, "data before birth");
+ return;
+ }
+
+ var sourceUtc = ToUtc(payload.TimestampMs);
+ var unknownAlias = false;
+
+ foreach (var metric in payload.Metrics)
+ {
+ var binding = ResolveBinding(table, metric);
+ if (binding is null)
+ {
+ // The birth this table was built from never declared this alias/name — our metadata is
+ // behind the node's. Same remedy as data-before-birth, different log.
+ unknownAlias = true;
+ continue;
+ }
+
+ PublishMetric(scope, binding, metric, sourceUtc);
+ }
+
+ if (unknownAlias)
+ {
+ WarnOnce(
+ $"alias:{scope}",
+ "MQTT driver '{DriverId}': data for '{Scope}' carried a metric the current birth does not "
+ + "declare; requesting a rebirth.",
+ _driverId,
+ scope);
+ RequestRebirth(node, "unknown alias");
+ }
+ }
+
+ ///
+ /// Resolves a DATA metric to its birth binding: by alias when it carries one (the normal
+ /// case — a DATA metric after a birth carries an alias and neither name nor datatype), otherwise
+ /// by name, since aliases are optional in Sparkplug.
+ ///
+ private static SparkplugMetricBinding? ResolveBinding(AliasTable table, SparkplugMetric metric)
+ {
+ if (metric.Alias is { } alias && table.Resolve(alias) is { } byAlias)
+ {
+ return byAlias;
+ }
+
+ return metric.Name is { } name ? table.ResolveByName(name) : null;
+ }
+
+ // ---- deaths ----
+
+ ///
+ /// Applies an NDEATH: verifies it belongs to the session currently believed alive, then forgets
+ /// the node and every device under it and stales their authored tags.
+ ///
+ private void OnNodeDeath(SparkplugNodeKey node, SparkplugPayload payload)
+ {
+ // NOT SequenceTracker.Accept. An NDEATH is the broker-published Last Will: it carries no seq at
+ // all, so Accept would report a gap on every death and answer a node that has just died with a
+ // rebirth request. The bdSeq tie is the whole mechanism instead.
+ var tracker = TrackerFor(node);
+ var deathBdSeq = SequenceTracker.TryReadBdSeq(payload, out var token) ? token : (ulong?)null;
+ if (!tracker.IsDeathForCurrentSession(deathBdSeq))
+ {
+ // A previous session's will, delivered after the node already reconnected and re-birthed.
+ // Acting on it would mark a live node dead with nothing coming to correct it.
+ _logger?.LogInformation(
+ "MQTT driver '{DriverId}': ignoring a stale NDEATH for '{Node}' (bdSeq {DeathBdSeq} ≠ current "
+ + "{BirthBdSeq}); the node re-birthed before its old will was delivered.",
+ _driverId,
+ node,
+ deathBdSeq,
+ tracker.BirthBdSeq);
+ return;
+ }
+
+ Births.ForgetNode(node.GroupId, node.EdgeNodeId);
+
+ var sourceUtc = ToUtc(payload.TimestampMs);
+ foreach (var scope in _authored.ScopesUnder(node))
+ {
+ FanStale(scope, sourceUtc, "its edge node published NDEATH");
+ }
+ }
+
+ /// Applies a DDEATH: forgets exactly that device and stales its authored tags.
+ private void OnDeviceDeath(SparkplugNodeKey node, string deviceId, SparkplugPayload payload)
+ {
+ // A DDEATH IS published by the edge node and IS sequenced — unlike an NDEATH.
+ CheckSequence(node, payload, "DDEATH");
+
+ Births.TryForgetDevice(node.GroupId, node.EdgeNodeId, deviceId, out _);
+
+ FanStale(
+ new SparkplugScope(node.GroupId, node.EdgeNodeId, deviceId),
+ ToUtc(payload.TimestampMs),
+ "the device published DDEATH");
+ }
+
+ // ---- STATE ----
+
+ ///
+ /// Records an observed primary-host STATE message.
+ ///
+ ///
+ ///
+ /// 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 MqttConnection.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.
+ ///
+ ///
+ /// Both payload forms are accepted on receive: the v3.0 JSON {"online":bool,...} and
+ /// the pre-3.0 bare ONLINE/OFFLINE text.
+ ///
+ ///
+ private void HandleState(SparkplugTopic topic, ReadOnlySpan payload)
+ {
+ if (topic.HostId is not { } hostId)
+ {
+ return;
+ }
+
+ if (!TryReadStateOnline(payload, out var online))
+ {
+ WarnOnce(
+ $"state:{hostId}",
+ "MQTT driver '{DriverId}': STATE for host '{HostId}' carried an unrecognised payload; ignored.",
+ _driverId,
+ hostId);
+ return;
+ }
+
+ var previous = _hostState;
+ var state = new SparkplugHostState(hostId, online, _utcNow());
+ _hostState = state;
+
+ if (previous is null || previous.Online != online || !string.Equals(previous.HostId, hostId, StringComparison.Ordinal))
+ {
+ _logger?.LogInformation(
+ "MQTT driver '{DriverId}': Sparkplug host '{HostId}' is {State}.",
+ _driverId,
+ hostId,
+ online ? "ONLINE" : "OFFLINE");
+ }
+
+ HostStateObserved?.Invoke(this, new SparkplugHostStateEventArgs(state));
+ }
+
+ /// Reads the online flag out of a STATE body — v3.0 JSON first, then the legacy text form.
+ private static bool TryReadStateOnline(ReadOnlySpan payload, out bool online)
+ {
+ online = false;
+ if (payload.IsEmpty)
+ {
+ return false;
+ }
+
+ try
+ {
+ var reader = new Utf8JsonReader(payload);
+ if (JsonDocument.TryParseValue(ref reader, out var doc))
+ {
+ using (doc)
+ {
+ if (doc.RootElement.ValueKind == JsonValueKind.Object
+ && doc.RootElement.TryGetProperty("online", out var flag)
+ && flag.ValueKind is JsonValueKind.True or JsonValueKind.False)
+ {
+ online = flag.GetBoolean();
+ return true;
+ }
+ }
+ }
+ }
+ catch (JsonException)
+ {
+ // Fall through to the legacy text form.
+ }
+
+ Span text = stackalloc char[16];
+ if (payload.Length > text.Length || !Encoding.UTF8.TryGetChars(payload, text, out var written))
+ {
+ return false;
+ }
+
+ var trimmed = text[..written].Trim();
+ if (trimmed.Equals("ONLINE", StringComparison.OrdinalIgnoreCase))
+ {
+ online = true;
+ return true;
+ }
+
+ if (trimmed.Equals("OFFLINE", StringComparison.OrdinalIgnoreCase))
+ {
+ online = false;
+ return true;
+ }
+
+ return false;
+ }
+
+ // ---- publish ----
+
+ ///
+ /// Publishes every named metric a birth certificate carried. This is what restores Good quality
+ /// after a death: a birth is a full snapshot, not a delta.
+ ///
+ private void PublishBirthValues(SparkplugScope scope, SparkplugPayload payload, DateTime? sourceUtc)
+ {
+ var table = Births.Find(scope);
+ if (table is null)
+ {
+ return;
+ }
+
+ foreach (var metric in payload.Metrics)
+ {
+ if (metric.Name is not { } name || table.ResolveByName(name) is not { } binding)
+ {
+ continue;
+ }
+
+ PublishMetric(scope, binding, metric, sourceUtc);
+ }
+ }
+
+ ///
+ /// Publishes one resolved metric to every authored tag bound to
+ /// (scope, binding.Name) — under the tag's RawPath.
+ ///
+ private void PublishMetric(
+ SparkplugScope scope,
+ SparkplugMetricBinding binding,
+ SparkplugMetric metric,
+ DateTime? sourceUtc)
+ {
+ // 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))
+ {
+ return; // A published metric nobody authored a tag for. Not an error — that is most of them.
+ }
+
+ var birthType = binding.DataType.ToDriverDataType();
+ if (birthType is null)
+ {
+ // DataSet / Template / PropertySet / Unknown — warn-and-skip per the datatype map's
+ // contract, and explicitly NOT a rebirth trigger: the node would answer with the same
+ // unsupported metric and the pair would loop.
+ WarnOnce(
+ $"unsupported-type:{key}",
+ "MQTT driver '{DriverId}': metric '{Metric}' on '{Scope}' has unsupported Sparkplug type "
+ + "{DataType}; its tag(s) are not being fed.",
+ _driverId,
+ binding.Name,
+ scope,
+ binding.DataType);
+ return;
+ }
+
+ var serverUtc = _utcNow();
+
+ foreach (var def in defs)
+ {
+ // The authored dataType wins when the operator declared one; otherwise the birth's own
+ // declaration does. That is what lets a tag be authored with nothing but the metric tuple.
+ var target = def.DataTypeAuthored ? def.DataType : birthType.Value;
+ if (BuildSnapshot(def, binding, metric, target, sourceUtc, serverUtc) is not { } snapshot)
+ {
+ continue;
+ }
+
+ Values.Update(def.Name, snapshot);
+ Raise(def.Name, snapshot);
+ }
+ }
+
+ ///
+ /// Turns one metric's wire value into a snapshot for one authored tag, or
+ /// when the metric carried no value at all — in which case the tag keeps whatever it last had
+ /// rather than being re-published unchanged or given an invented quality.
+ ///
+ private DataValueSnapshot? BuildSnapshot(
+ MqttTagDefinition def,
+ SparkplugMetricBinding binding,
+ SparkplugMetric metric,
+ DriverDataType target,
+ DateTime? sourceUtc,
+ DateTime serverUtc)
+ {
+ // Metric timestamp wins over the payload's — it is the acquisition instant at the source.
+ var stamp = ToUtc(metric.TimestampMs) ?? sourceUtc;
+
+ switch (metric.ValueKind)
+ {
+ case SparkplugValueKind.Scalar:
+ break;
+
+ case SparkplugValueKind.Null:
+ // An explicit is_null. The tag has a declared scalar type and null is not a value of
+ // it — the same verdict the plain-mode path reaches for a JSON null.
+ return new DataValueSnapshot(null, StatusBadTypeMismatch, stamp, serverUtc);
+
+ default:
+ // Absent (no value oneof at all) or Unsupported (DataSet/Template). Neither is a value;
+ // leave the tag holding whatever it last had rather than inventing a quality for it.
+ WarnOnce(
+ $"valuekind:{def.Name}",
+ "MQTT driver '{DriverId}': metric '{Metric}' for '{RawPath}' carried {ValueKind} rather "
+ + "than a value; skipped.",
+ _driverId,
+ binding.Name,
+ def.Name,
+ metric.ValueKind);
+ return null;
+ }
+
+ // THE call. Sparkplug carries signed integers as two's complement in an unsigned field, and a
+ // DATA metric carries no datatype of its own — only the birth binding knows which applies.
+ // Skipping this publishes 4294967254 for a value of -42: plausible, Good, and wrong.
+ var raw = binding.Reinterpret(metric.Value);
+
+ return SparkplugValueCoercion.TryCoerce(raw, target, out var value)
+ ? new DataValueSnapshot(value, StatusGood, stamp, serverUtc)
+ : BadTypeMismatch(def, binding, target, stamp, serverUtc);
+ }
+
+ ///
+ /// 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.
+ ///
+ private DataValueSnapshot BadTypeMismatch(
+ MqttTagDefinition def,
+ SparkplugMetricBinding binding,
+ DriverDataType target,
+ DateTime? stamp,
+ DateTime serverUtc)
+ {
+ WarnOnce(
+ $"coerce:{def.Name}",
+ "MQTT driver '{DriverId}': metric '{Metric}' ({Sparkplug}) does not fit tag '{RawPath}'s "
+ + "{Target}; status BadTypeMismatch.",
+ _driverId,
+ binding.Name,
+ binding.DataType,
+ def.Name,
+ target);
+
+ return new DataValueSnapshot(null, StatusBadTypeMismatch, stamp, serverUtc);
+ }
+
+ /// Marks every authored tag in as no longer communicating.
+ private void FanStale(SparkplugScope scope, DateTime? sourceUtc, string reason)
+ {
+ if (!_authored.ByScope.TryGetValue(scope, out var defs))
+ {
+ return;
+ }
+
+ var snapshot = new DataValueSnapshot(null, StatusBadCommunicationError, sourceUtc, _utcNow());
+ foreach (var def in defs)
+ {
+ Values.Update(def.Name, snapshot);
+ Raise(def.Name, snapshot);
+ }
+
+ _logger?.LogInformation(
+ "MQTT driver '{DriverId}': staled {Count} tag(s) on '{Scope}' — {Reason}.",
+ _driverId,
+ defs.Length,
+ scope,
+ reason);
+ }
+
+ ///
+ /// Raises for a RawPath, if a live subscription covers it. A throwing
+ /// subscriber is contained so it cannot starve the rest of the fan-out or escape onto MQTTnet's
+ /// dispatcher thread.
+ ///
+ private void Raise(string rawPath, DataValueSnapshot snapshot)
+ {
+ if (!_handleByRawPath.TryGetValue(rawPath, out var handle))
+ {
+ return; // Authored but not subscribed: cached so a read answers, nothing to attribute to.
+ }
+
+ try
+ {
+ OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, rawPath, snapshot));
+ }
+ catch (Exception ex)
+ {
+ _logger?.LogError(
+ ex,
+ "MQTT driver '{DriverId}': a data-change subscriber threw for '{RawPath}'.",
+ _driverId,
+ rawPath);
+ }
+ }
+
+ private void RaiseBirthObserved(SparkplugScope scope, SparkplugPayload payload)
+ {
+ var subscribers = BirthObserved;
+ if (subscribers is null)
+ {
+ return;
+ }
+
+ try
+ {
+ subscribers(this, new SparkplugBirthObservedEventArgs(
+ scope,
+ [.. payload.Metrics.Where(m => m.Name is not null).Select(m => m.Name!)]));
+ }
+ catch (Exception ex)
+ {
+ _logger?.LogError(ex, "MQTT driver '{DriverId}': a birth-observed subscriber threw.", _driverId);
+ }
+ }
+
+ // ---- sequence + rebirth ----
+
+ private SequenceTracker TrackerFor(SparkplugNodeKey node) =>
+ _trackers.GetOrAdd(node, static _ => new SequenceTracker());
+
+ ///
+ /// Offers a sequenced message's seq and requests a rebirth when the stream is not
+ /// contiguous — or when it is contiguous only because the driver joined mid-stream and has never
+ /// seen this node's NBIRTH.
+ ///
+ ///
+ /// The second case is the one that is easy to miss: returns
+ /// for the first message after a reset (there is nothing to measure it
+ /// against), so a driver that only checked its return value would sit happily behind a stream
+ /// whose birth certificate — and therefore whose entire alias table — it never received.
+ ///
+ private void CheckSequence(SparkplugNodeKey node, SparkplugPayload payload, string kind)
+ {
+ var tracker = TrackerFor(node);
+ if (!tracker.Accept(payload.Seq))
+ {
+ WarnOnce(
+ $"gap:{node}",
+ "MQTT driver '{DriverId}': {Kind} from '{Node}' broke the sequence (seq {Seq}, last {Last}); "
+ + "requesting a rebirth.",
+ _driverId,
+ kind,
+ node,
+ payload.Seq,
+ tracker.LastSeq);
+ RequestRebirth(node, "sequence gap");
+ return;
+ }
+
+ if (!tracker.IsBirthSynchronized)
+ {
+ WarnOnce(
+ $"latejoin:{node}",
+ "MQTT driver '{DriverId}': {Kind} from '{Node}' arrived with no NBIRTH seen since connect; "
+ + "requesting a rebirth.",
+ _driverId,
+ kind,
+ node);
+ RequestRebirth(node, "late join (no NBIRTH observed)");
+ }
+ }
+
+ ///
+ /// Requests a rebirth from every authored edge node — §3.6 invariant #5. Called on connect and
+ /// on every reconnect.
+ ///
+ ///
+ /// Bounded by configuration, not by the plant. Sparkplug has no group-wide rebirth
+ /// command — NCMD addresses exactly one edge node — so this is inherently one publish per node.
+ /// The set is the authored edge nodes rather than every node in the group, which is what
+ /// keeps a 200-node group from producing 200 NCMDs for the two nodes this deployment reads. Each
+ /// one still passes through the per-node debounce, so a connection that flaps cannot multiply
+ /// them.
+ ///
+ private void RequestLateJoinRebirths(string reason)
+ {
+ foreach (var node in _authored.EdgeNodes)
+ {
+ RequestRebirth(node, reason);
+ }
+ }
+
+ ///
+ /// Publishes a rebirth NCMD to one edge node, subject to the
+ /// policy and the per-node debounce.
+ /// Never blocks the caller and never throws.
+ ///
+ private void RequestRebirth(SparkplugNodeKey node, string reason)
+ {
+ if (_options.Sparkplug is { RequestRebirthOnGap: false })
+ {
+ _logger?.LogDebug(
+ "MQTT driver '{DriverId}': would request a rebirth from '{Node}' ({Reason}) but "
+ + "requestRebirthOnGap is off.",
+ _driverId,
+ node,
+ reason);
+ return;
+ }
+
+ if (_publishTransport is not { } transport)
+ {
+ _logger?.LogDebug(
+ "MQTT driver '{DriverId}': cannot request a rebirth from '{Node}' ({Reason}) — no publish "
+ + "transport is attached.",
+ _driverId,
+ node,
+ reason);
+ return;
+ }
+
+ if (!TryAcquireRebirthSlot(node))
+ {
+ _logger?.LogDebug(
+ "MQTT driver '{DriverId}': rebirth request to '{Node}' ({Reason}) suppressed; one was sent "
+ + "less than {Debounce} ago.",
+ _driverId,
+ node,
+ reason,
+ _rebirthDebounce);
+ return;
+ }
+
+ _logger?.LogInformation(
+ "MQTT driver '{DriverId}': requesting a Sparkplug rebirth from '{Node}' — {Reason}.",
+ _driverId,
+ node,
+ reason);
+
+ // Off the dispatcher thread: RebirthRequester.RequestAsync serializes a payload and hands it to
+ // MQTTnet's publish path, and neither belongs on the thread that is delivering every other
+ // subscription's messages. It is bounded by its own 5s deadline.
+ TrackRebirth(Task.Run(async () =>
+ {
+ try
+ {
+ await RebirthRequester
+ .RequestAsync(transport, node.GroupId, node.EdgeNodeId, CancellationToken.None)
+ .ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger?.LogWarning(
+ ex,
+ "MQTT driver '{DriverId}': the rebirth NCMD to '{Node}' failed; the next gap will retry.",
+ _driverId,
+ node);
+ }
+ }));
+ }
+
+ ///
+ /// Claims this node's rebirth slot, or reports that one was claimed too recently. Lock-free: this
+ /// runs on MQTTnet's dispatcher thread.
+ ///
+ private bool TryAcquireRebirthSlot(SparkplugNodeKey node)
+ {
+ if (_rebirthDebounce <= TimeSpan.Zero)
+ {
+ return true;
+ }
+
+ var now = _utcNow().Ticks;
+ while (true)
+ {
+ if (!_lastRebirthTicks.TryGetValue(node, out var last))
+ {
+ if (_lastRebirthTicks.TryAdd(node, now))
+ {
+ return true;
+ }
+
+ continue;
+ }
+
+ if (now - last < _rebirthDebounce.Ticks)
+ {
+ return false;
+ }
+
+ if (_lastRebirthTicks.TryUpdate(node, now, last))
+ {
+ return true;
+ }
+ }
+ }
+
+ private void TrackRebirth(Task task)
+ {
+ lock (_pendingRebirths)
+ {
+ _pendingRebirths.RemoveAll(static t => t.IsCompleted);
+ _pendingRebirths.Add(task);
+ }
+ }
+
+ // ---- subscribe ----
+
+ /// Issues the group-wide (and STATE) SUBSCRIBE, throwing when nothing could be established.
+ private async Task SubscribeGroupAsync(CancellationToken cancellationToken)
+ {
+ if (GroupFilter is not { } groupFilter)
+ {
+ throw new InvalidOperationException(
+ $"MQTT driver '{_driverId}': Sparkplug mode requires a groupId — there is no filter to "
+ + "subscribe, so the driver would receive nothing.");
+ }
+
+ // QoS 1 for both. Sparkplug publishes NBIRTH/DBIRTH/DATA at QoS 0 and NDEATH/STATE at QoS 1;
+ // the SUBSCRIBE's QoS is the ceiling the broker may deliver at, so asking for 1 keeps the
+ // death and state messages at their intended guarantee and costs nothing for the rest.
+ var filters = new List(2)
+ {
+ // SeedRetained: a retained NBIRTH/STATE replayed at subscribe time IS the late-join
+ // metadata this driver wants — the opposite of the plain-mode tag opt-out.
+ new(groupFilter, Qos: 1, SeedRetained: true),
+ };
+
+ if (StateFilter is { } stateFilter)
+ {
+ filters.Add(new MqttTopicSubscription(stateFilter, Qos: 1, SeedRetained: true));
+ }
+
+ var transport = _subscribeTransport;
+ if (transport is null)
+ {
+ _logger?.LogWarning(
+ "MQTT driver '{DriverId}': Sparkplug subscription recorded with no connection attached; "
+ + "nothing was sent to a broker. Call AttachTo before establishing.",
+ _driverId);
+ return;
+ }
+
+ var outcomes = await transport.SubscribeAsync(filters, cancellationToken).ConfigureAwait(false);
+
+ // The group filter is the one that matters: without it the driver is deaf. A refused STATE
+ // filter costs an observability signal, not the data plane, so it warns rather than throws.
+ var groupGranted = outcomes.Any(o => o.Granted && string.Equals(o.Topic, groupFilter, StringComparison.Ordinal));
+ if (!groupGranted)
+ {
+ throw new InvalidOperationException(
+ $"MQTT driver '{_driverId}': the broker refused '{groupFilter}'; tearing the session down "
+ + "rather than serving a Sparkplug driver that receives nothing.");
+ }
+
+ foreach (var refused in outcomes.Where(o => !o.Granted))
+ {
+ _logger?.LogWarning(
+ "MQTT driver '{DriverId}': broker refused SUBSCRIBE '{Topic}' ({Reason}).",
+ _driverId,
+ refused.Topic,
+ refused.Reason);
+ }
+ }
+
+ // ---- helpers ----
+
+ private void LogBirthAnomaly(string scope, BirthApplyResult result)
+ {
+ if (!result.HasAnomaly)
+ {
+ return;
+ }
+
+ // The only window onto a malformed birth — AliasTable takes no logger by design.
+ _logger?.LogWarning(
+ "MQTT driver '{DriverId}': birth for '{Scope}' was malformed — {Accepted} accepted, "
+ + "{Unnamed} unnamed, {AliasCollisions} alias collision(s), {DuplicateNames} duplicate name(s).",
+ _driverId,
+ scope,
+ result.Accepted,
+ result.RejectedUnnamed,
+ result.AliasCollisions,
+ result.DuplicateNames);
+ }
+
+ private static DateTime? ToUtc(ulong? epochMs) =>
+ epochMs is { } ms && ms <= (ulong)DateTimeOffset.MaxValue.ToUnixTimeMilliseconds()
+ ? DateTimeOffset.FromUnixTimeMilliseconds((long)ms).UtcDateTime
+ : null;
+
+ ///
+ /// Logs a warning the first time is seen and stays quiet thereafter. A
+ /// 10 Hz metric with a mis-authored datatype must be diagnosable without drowning the log; the
+ /// key set is bounded because every path that calls this has already filtered to authored nodes,
+ /// scopes or RawPaths, and it is cleared on every .
+ ///
+ private void WarnOnce(string key, string message, params object?[] args)
+ {
+ if (_warned.TryAdd(key, 0))
+ {
+#pragma warning disable CA2254 // Template is a compile-time constant at every call site.
+ _logger?.LogWarning(message, args);
+#pragma warning restore CA2254
+ }
+ }
+
+ /// Opaque subscription identity for the Sparkplug ingest path.
+ private sealed record SparkplugSubscriptionHandle(string DiagnosticId) : ISubscriptionHandle;
+
+ ///
+ /// The authored tag table and its Sparkplug indexes, published as one immutable snapshot so the
+ /// dispatcher thread reads a consistent view while a redeploy rebuilds it.
+ ///
+ /// RawPath → definition; the v3 resolver's backing table.
+ ///
+ /// (group, node, device?, metricName) → every definition bound to it. A list,
+ /// because two raw tags may legitimately project the same Sparkplug metric.
+ ///
+ /// Scope → its definitions; the death/invalidation STALE fan-out's input.
+ ///
+ /// The distinct (group, edgeNode) pairs any authored tag names — the ingest filter, the
+ /// late-join rebirth set, and what bounds the per-node tracker map.
+ ///
+ private sealed record AuthoredTable(
+ FrozenDictionary ByRawPath,
+ FrozenDictionary ByMetric,
+ FrozenDictionary ByScope,
+ FrozenSet EdgeNodes)
+ {
+ public static readonly AuthoredTable Empty =
+ Build(new Dictionary(StringComparer.Ordinal));
+
+ /// Every authored scope belonging to one edge node — the NDEATH fan-out set.
+ /// The edge node.
+ /// The scopes, node-own and device alike.
+ public IEnumerable ScopesUnder(SparkplugNodeKey node) =>
+ ByScope.Keys.Where(s => s.IsUnder(node.GroupId, node.EdgeNodeId));
+
+ /// Builds the snapshot from the mapped definitions.
+ /// The mapped definitions, keyed by RawPath.
+ /// The immutable snapshot.
+ public static AuthoredTable Build(IReadOnlyDictionary byRawPath)
+ {
+ var byMetric = new Dictionary>();
+ var byScope = new Dictionary>();
+ var nodes = new HashSet();
+
+ foreach (var def in byRawPath.Values)
+ {
+ // The factory guarantees all three; a definition that reached here without them could
+ // never be routed, so it is skipped rather than indexed under a nonsense key.
+ if (def.GroupId is not { } group || def.EdgeNodeId is not { } node || def.MetricName is not { } metric)
+ {
+ continue;
+ }
+
+ var scope = new SparkplugScope(group, node, def.DeviceId);
+ nodes.Add(new SparkplugNodeKey(group, node));
+
+ if (!byMetric.TryGetValue(new SparkplugMetricKey(group, node, def.DeviceId, metric), out var metricList))
+ {
+ byMetric[new SparkplugMetricKey(group, node, def.DeviceId, metric)] = metricList = [];
+ }
+
+ metricList.Add(def);
+
+ if (!byScope.TryGetValue(scope, out var scopeList))
+ {
+ byScope[scope] = scopeList = [];
+ }
+
+ scopeList.Add(def);
+ }
+
+ return new AuthoredTable(
+ byRawPath.ToFrozenDictionary(StringComparer.Ordinal),
+ byMetric.ToFrozenDictionary(kv => kv.Key, kv => kv.Value.ToArray()),
+ byScope.ToFrozenDictionary(kv => kv.Key, kv => kv.Value.ToArray()),
+ nodes.ToFrozenSet());
+ }
+ }
+}
+
+///
+/// One edge node's identity — the unit Sparkplug sequences messages by, and the unit a rebirth NCMD
+/// is addressed to.
+///
+/// The Sparkplug group id.
+/// The Sparkplug edge-node id.
+public readonly record struct SparkplugNodeKey(string GroupId, string EdgeNodeId)
+{
+ ///
+ public override string ToString() => $"{GroupId}/{EdgeNodeId}";
+}
+
+///
+/// An authored tag's Sparkplug binding key: the stable tuple a tag is resolved by, across every
+/// rebirth. The metric name is part of it; the per-birth alias never is.
+///
+/// The Sparkplug group id.
+/// The Sparkplug edge-node id.
+/// The Sparkplug device id, or for a node-level metric.
+/// The stable metric name.
+public readonly record struct SparkplugMetricKey(
+ string GroupId,
+ string EdgeNodeId,
+ string? DeviceId,
+ string MetricName)
+{
+ ///
+ public override string ToString() =>
+ DeviceId is null
+ ? $"{GroupId}/{EdgeNodeId}:{MetricName}"
+ : $"{GroupId}/{EdgeNodeId}/{DeviceId}:{MetricName}";
+}
+
+/// An observed Sparkplug primary-host STATE.
+/// The host id the STATE topic named.
+/// Whether the host declared itself online.
+/// When this driver observed it.
+public sealed record SparkplugHostState(string HostId, bool Online, DateTime ObservedUtc);
+
+/// Event payload for .
+/// The observed state.
+public sealed class SparkplugHostStateEventArgs(SparkplugHostState state) : EventArgs
+{
+ /// The observed state.
+ public SparkplugHostState State { get; } = state;
+}
+
+///
+/// Event payload for — the Task-22 rediscovery seam.
+///
+/// The scope whose birth certificate was applied.
+/// The named metrics the birth declared, in wire order.
+public sealed class SparkplugBirthObservedEventArgs(SparkplugScope scope, IReadOnlyList metricNames)
+ : EventArgs
+{
+ /// The scope whose birth certificate was applied.
+ public SparkplugScope Scope { get; } = scope;
+
+ /// The named metrics the birth declared.
+ public IReadOnlyList MetricNames { get; } = metricNames;
+}
+
+///
+/// Coerces a Sparkplug wire value — after
+/// has undone the two's-complement encoding — onto
+/// an authored tag's .
+///
+///
+///
+/// A value that does not fit is refused, never silently converted. Same rule the
+/// plain-MQTT path follows: a wrong value delivered at Good quality is worse than no value.
+/// The one deliberate latitude is an exactly integral real (5.0) for an integer
+/// tag — edge gateways emit those routinely — while a genuinely fractional one is refused
+/// rather than rounded.
+///
+///
+/// Sparkplug DateTime is epoch milliseconds, carried in the unsigned 64-bit
+/// field, not an ISO string. Reading it as a number and calling it a timestamp is the whole
+/// conversion.
+///
+///
+internal static class SparkplugValueCoercion
+{
+ /// Coerces onto .
+ /// The reinterpreted wire value.
+ /// The tag's effective data type.
+ /// The coerced value when this returns .
+ /// when the value fits the target type.
+ public static bool TryCoerce(object? raw, DriverDataType target, out object? value)
+ {
+ value = null;
+ if (raw is null)
+ {
+ return false;
+ }
+
+ try
+ {
+ switch (target)
+ {
+ case DriverDataType.String:
+ case DriverDataType.Reference:
+ // Bytes/File metrics map to String per the datatype map; base64 is the v1 fallback
+ // the design names, and it round-trips where a lossy UTF-8 decode would not.
+ value = raw switch
+ {
+ string s => s,
+ byte[] bytes => Convert.ToBase64String(bytes),
+ bool b => b ? "true" : "false",
+ _ => Convert.ToString(raw, CultureInfo.InvariantCulture),
+ };
+ return value is not null;
+
+ case DriverDataType.Boolean:
+ return TryCoerceBoolean(raw, out value);
+
+ case DriverDataType.Int16:
+ case DriverDataType.Int32:
+ case DriverDataType.Int64:
+ case DriverDataType.UInt16:
+ case DriverDataType.UInt32:
+ case DriverDataType.UInt64:
+ return TryCoerceInteger(raw, target, out value);
+
+ case DriverDataType.Float32:
+ if (raw is string f32Text)
+ {
+ return float.TryParse(f32Text, NumberStyles.Float, CultureInfo.InvariantCulture, out var pf)
+ && Assign(pf, out value);
+ }
+
+ value = Convert.ToSingle(raw, CultureInfo.InvariantCulture);
+ return true;
+
+ case DriverDataType.Float64:
+ if (raw is string f64Text)
+ {
+ return double.TryParse(f64Text, NumberStyles.Float, CultureInfo.InvariantCulture, out var pd)
+ && Assign(pd, out value);
+ }
+
+ value = Convert.ToDouble(raw, CultureInfo.InvariantCulture);
+ return true;
+
+ case DriverDataType.DateTime:
+ return TryCoerceDateTime(raw, out value);
+
+ default:
+ return false;
+ }
+ }
+ catch (Exception ex) when (ex is OverflowException or FormatException or InvalidCastException)
+ {
+ // Out of range, unparseable, or a wire type that cannot become the target at all. All three
+ // are "does not fit", which is a Bad status on one tag — never an exception on the
+ // dispatcher thread.
+ value = null;
+ return false;
+ }
+ }
+
+ private static bool Assign(T parsed, out object? value)
+ {
+ value = parsed;
+ return true;
+ }
+
+ private static bool TryCoerceBoolean(object raw, out object? value)
+ {
+ switch (raw)
+ {
+ case bool b:
+ value = b;
+ return true;
+ case string s when bool.TryParse(s.Trim(), out var parsed):
+ value = parsed;
+ return true;
+ case string s:
+ value = null;
+ return long.TryParse(s.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var n)
+ && Assign(n != 0L, out value);
+ case byte[]:
+ value = null;
+ return false;
+ default:
+ // Any numeric wire type: nonzero is true, matching the plain-mode path's rule.
+ value = Convert.ToDouble(raw, CultureInfo.InvariantCulture) != 0d;
+ return true;
+ }
+ }
+
+ private static bool TryCoerceInteger(object raw, DriverDataType target, out object? value)
+ {
+ value = null;
+
+ // Reals (and textual reals) must be exactly integral: rounding 5.5 to 5 or 6 is a wrong value,
+ // which is worse than no value. Parsed as decimal so the test and the conversion stay exact
+ // across the whole Int64/UInt64 range, where double loses precision above 2^53.
+ if (raw is float or double or decimal or string)
+ {
+ var text = raw as string ?? Convert.ToString(raw, CultureInfo.InvariantCulture);
+ if (text is null
+ || !decimal.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var dec)
+ || dec != decimal.Truncate(dec))
+ {
+ return false;
+ }
+
+ raw = dec;
+ }
+
+ value = target switch
+ {
+ DriverDataType.Int16 => Convert.ToInt16(raw, CultureInfo.InvariantCulture),
+ DriverDataType.Int32 => Convert.ToInt32(raw, CultureInfo.InvariantCulture),
+ DriverDataType.Int64 => Convert.ToInt64(raw, CultureInfo.InvariantCulture),
+ DriverDataType.UInt16 => Convert.ToUInt16(raw, CultureInfo.InvariantCulture),
+ DriverDataType.UInt32 => Convert.ToUInt32(raw, CultureInfo.InvariantCulture),
+ DriverDataType.UInt64 => Convert.ToUInt64(raw, CultureInfo.InvariantCulture),
+ _ => null,
+ };
+
+ return value is not null;
+ }
+
+ private static bool TryCoerceDateTime(object raw, out object? value)
+ {
+ value = null;
+
+ switch (raw)
+ {
+ case DateTime dt:
+ value = dt;
+ return true;
+
+ case string s:
+ return DateTime.TryParse(
+ s,
+ CultureInfo.InvariantCulture,
+ DateTimeStyles.RoundtripKind | DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal,
+ out var parsed)
+ && Assign(parsed, out value);
+
+ case bool:
+ case byte[]:
+ return false;
+
+ default:
+ // Sparkplug DateTime rides as epoch milliseconds in the unsigned 64-bit value field.
+ var ms = Convert.ToInt64(raw, CultureInfo.InvariantCulture);
+ if (ms < DateTimeOffset.MinValue.ToUnixTimeMilliseconds()
+ || ms > DateTimeOffset.MaxValue.ToUnixTimeMilliseconds())
+ {
+ return false;
+ }
+
+ value = DateTimeOffset.FromUnixTimeMilliseconds(ms).UtcDateTime;
+ return true;
+ }
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugIngestorTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugIngestorTests.cs
new file mode 100644
index 00000000..4dde20b9
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugIngestorTests.cs
@@ -0,0 +1,1308 @@
+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;
+
+///
+/// Covers (Task 21) — the design doc's §3.6 correctness matrix:
+/// rebirth, missed birth, seq gap, alias reuse across a rebirth, death → stale → rebirth, plus
+/// data-before-birth and unknown-alias. Every test runs without a broker: payloads are built with
+/// the generated Tahu types, decoded through the real , and fed to
+/// (the method
+/// calls on MQTTnet's dispatcher thread).
+///
+///
+/// The identity assertion is the point of half of these. The plan's illustrative snippet
+/// asserts firedRef.ShouldContain("Filler1:Temperature") — the retired pre-v3 shape. A
+/// Sparkplug-composed reference satisfies it and is silently dead against DriverHostActor's
+/// RawPath-keyed dual-namespace fan-out, so every publish here is asserted to be the RawPath
+/// and to not be the composed form.
+///
+public sealed class SparkplugIngestorTests
+{
+ // OPC UA status codes, verified against Opc.Ua.StatusCodes rather than recalled.
+ private const uint Good = 0x00000000u;
+ private const uint BadCommunicationError = 0x80050000u;
+ private const uint BadTypeMismatch = 0x80740000u;
+ private const uint BadNodeIdUnknown = 0x80340000u;
+
+ private const string Group = "Plant1";
+ private const string Node = "EdgeA";
+ private const string Device = "Filler1";
+
+ private const string TempPath = "Plant/Mqtt/spb/Filler1Temp";
+ private const string PressPath = "Plant/Mqtt/spb/Filler1Press";
+ private const string CountPath = "Plant/Mqtt/spb/Filler1Count";
+ private const string NodeUptimePath = "Plant/Mqtt/spb/EdgeAUptime";
+
+ /// The retired pre-v3 composed shape — asserted ABSENT, never present.
+ private const string ComposedRef = "Plant1/EdgeA/Filler1:Temperature";
+
+ private const ulong TempAlias = 5UL;
+ private const ulong PressAlias = 6UL;
+ private const ulong CountAlias = 9UL;
+
+ // ---------------------------------------------------------------------------------
+ // §3.6 #1/#2 — birth → data, resolved BY ALIAS, published UNDER THE RAWPATH
+ // ---------------------------------------------------------------------------------
+
+ ///
+ /// The plan's first snippet, corrected: the published reference is asserted to BE the tag's
+ /// RawPath, not merely to contain the composed Filler1:Temperature form.
+ ///
+ [Fact]
+ public async Task BirthThenData_ResolvesByAlias_RaisesOnDataChangeUnderRawPath()
+ {
+ var ing = await NewSubscribedIngestorAsync();
+
+ string? firedRef = null;
+ object? firedValue = null;
+ ing.OnDataChange += (_, e) => { firedRef = e.FullReference; firedValue = e.Snapshot.Value; };
+
+ ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 0, bdSeq: 1));
+ ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Dbirth(seq: 1));
+ ing.Dispatch(
+ Topic(SparkplugMessageType.DDATA, Device),
+ Ddata(seq: 2, (TempAlias, TahuDataType.Float, 21.5f)));
+
+ firedRef.ShouldBe(TempPath);
+ firedRef.ShouldNotBe(ComposedRef);
+ firedValue.ShouldBe(21.5f);
+ }
+
+ ///
+ /// Falsifiability control for the identity assertion above: the composed Sparkplug reference —
+ /// the shape the plan's own snippet would have accepted — must never be published.
+ ///
+ [Fact]
+ public async Task PublishedReference_IsNeverTheComposedSparkplugForm()
+ {
+ var ing = await NewSubscribedIngestorAsync();
+ var refs = new List();
+ ing.OnDataChange += (_, e) => refs.Add(e.FullReference);
+
+ FeedBirths(ing);
+ ing.Dispatch(
+ Topic(SparkplugMessageType.DDATA, Device),
+ Ddata(seq: 2, (TempAlias, TahuDataType.Float, 21.5f)));
+
+ refs.ShouldNotBeEmpty();
+ refs.ShouldAllBe(r => r == TempPath || r == PressPath || r == CountPath || r == NodeUptimePath);
+ refs.ShouldNotContain(ComposedRef);
+ }
+
+ /// A DATA metric carries an alias and NO name — the shape every real Sparkplug DATA has.
+ [Fact]
+ public async Task DataMetric_CarriesNoNameOrDatatype_StillResolves()
+ {
+ var ing = await NewSubscribedIngestorAsync();
+ FeedBirths(ing);
+
+ var payload = Ddata(seq: 2, (TempAlias, TahuDataType.Float, 33.5f));
+ payload.Metrics.ShouldAllBe(m => m.Name == null && m.DataType == null);
+
+ var seen = new Dictionary(StringComparer.Ordinal);
+ ing.OnDataChange += (_, e) => seen[e.FullReference] = e.Snapshot;
+ ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), payload);
+
+ seen[TempPath].Value.ShouldBe(33.5f);
+ seen[TempPath].StatusCode.ShouldBe(Good);
+ }
+
+ /// A node-scoped metric (NDATA) binds against the node's own scope, not a device's.
+ [Fact]
+ public async Task NodeScopedMetric_BindsToTheNodeScope()
+ {
+ var ing = await NewSubscribedIngestorAsync();
+ var seen = new Dictionary(StringComparer.Ordinal);
+ ing.OnDataChange += (_, e) => seen[e.FullReference] = e.Snapshot.Value;
+
+ ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 0, bdSeq: 1));
+ ing.Dispatch(Topic(SparkplugMessageType.NDATA), Ndata(seq: 1, (7UL, TahuDataType.Int64, 99L)));
+
+ seen[NodeUptimePath].ShouldBe(99L);
+ seen.ShouldNotContainKey(TempPath);
+ }
+
+ // ---------------------------------------------------------------------------------
+ // §3.6 #1 — ALIAS REUSE ACROSS A REBIRTH (the mis-route this whole design prevents)
+ // ---------------------------------------------------------------------------------
+
+ ///
+ /// The single most important test in the file. An edge node is free to point alias 5 at a
+ /// different metric after a rebirth. Binding a tag to an alias silently routes Pressure into the
+ /// Temperature tag — good quality, plausible value, completely wrong.
+ ///
+ [Fact]
+ public async Task AliasReusedAcrossRebirth_RoutesByName_NotByAlias()
+ {
+ var ing = await NewSubscribedIngestorAsync();
+ var seen = new Dictionary(StringComparer.Ordinal);
+ ing.OnDataChange += (_, e) => seen[e.FullReference] = e.Snapshot.Value;
+
+ ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 0, bdSeq: 1));
+ ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Dbirth(seq: 1));
+ ing.Dispatch(
+ Topic(SparkplugMessageType.DDATA, Device),
+ Ddata(seq: 2, (TempAlias, TahuDataType.Float, 21.5f)));
+ seen[TempPath].ShouldBe(21.5f);
+
+ // Rebirth: the SAME alias 5 now names Pressure, and Temperature moves to alias 6.
+ ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 0, bdSeq: 2));
+ ing.Dispatch(
+ Topic(SparkplugMessageType.DBIRTH, Device),
+ DbirthCustom(
+ seq: 1,
+ ("Pressure", TempAlias, TahuDataType.Float, 1.0f),
+ ("Temperature", PressAlias, TahuDataType.Float, 2.0f)));
+
+ seen.Clear();
+ ing.Dispatch(
+ Topic(SparkplugMessageType.DDATA, Device),
+ Ddata(seq: 2, (TempAlias, TahuDataType.Float, 4.25f)));
+
+ // Alias 5 is Pressure NOW. A tag bound by alias would put 4.25 on the Temperature RawPath.
+ seen.ShouldContainKey(PressPath);
+ seen[PressPath].ShouldBe(4.25f);
+ seen.ShouldNotContainKey(TempPath);
+ }
+
+ ///
+ /// A rebirth REPLACES the catalog: an alias the new birth does not declare must stop resolving,
+ /// rather than lingering from the previous birth's table.
+ ///
+ [Fact]
+ public async Task Rebirth_DroppingAMetric_StopsFeedingIt()
+ {
+ var ing = await NewSubscribedIngestorAsync();
+ var seen = new List();
+ ing.OnDataChange += (_, e) => seen.Add(e.FullReference);
+
+ FeedBirths(ing);
+ ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 0, bdSeq: 2));
+ ing.Dispatch(
+ Topic(SparkplugMessageType.DBIRTH, Device),
+ DbirthCustom(seq: 1, ("Pressure", PressAlias, TahuDataType.Float, 0f)));
+
+ seen.Clear();
+ ing.Dispatch(
+ Topic(SparkplugMessageType.DDATA, Device),
+ Ddata(seq: 2, (TempAlias, TahuDataType.Float, 21.5f)));
+
+ seen.ShouldNotContain(TempPath);
+ }
+
+ // ---------------------------------------------------------------------------------
+ // §3.6 #3 — data before birth / unknown alias / seq gap ⇒ rebirth NCMD
+ // ---------------------------------------------------------------------------------
+
+ /// The plan's second snippet, against the real NCMD publish seam.
+ [Fact]
+ public async Task DataBeforeBirth_RequestsRebirth()
+ {
+ var publish = new RecordingPublishTransport();
+ var ing = await NewSubscribedIngestorAsync(publish);
+
+ ing.Dispatch(
+ Topic(SparkplugMessageType.DDATA, Device),
+ Ddata(seq: 0, (CountAlias, TahuDataType.Float, 1f)));
+ await ing.DrainPendingRebirthsAsync();
+
+ publish.Topics.ShouldContain("spBv1.0/Plant1/NCMD/EdgeA");
+ }
+
+ /// An alias the current birth never declared is our metadata being behind the node's.
+ [Fact]
+ public async Task UnknownAlias_RequestsRebirth()
+ {
+ var publish = new RecordingPublishTransport();
+ var ing = await NewSubscribedIngestorAsync(publish);
+ FeedBirths(ing);
+ publish.Topics.Clear();
+
+ ing.Dispatch(
+ Topic(SparkplugMessageType.DDATA, Device),
+ Ddata(seq: 2, (4242UL, TahuDataType.Float, 1f)));
+ await ing.DrainPendingRebirthsAsync();
+
+ publish.Topics.ShouldContain("spBv1.0/Plant1/NCMD/EdgeA");
+ }
+
+ /// A missed message in the edge node's stream — §3.6 #3.
+ [Fact]
+ public async Task SeqGap_RequestsRebirth()
+ {
+ var publish = new RecordingPublishTransport();
+ var ing = await NewSubscribedIngestorAsync(publish);
+ FeedBirths(ing);
+ publish.Topics.Clear();
+
+ // seq 2 was expected; 4 means two messages were lost.
+ ing.Dispatch(
+ Topic(SparkplugMessageType.DDATA, Device),
+ Ddata(seq: 4, (TempAlias, TahuDataType.Float, 1f)));
+ await ing.DrainPendingRebirthsAsync();
+
+ publish.Topics.ShouldContain("spBv1.0/Plant1/NCMD/EdgeA");
+ }
+
+ ///
+ /// Falsifiability control for every rebirth test above: a contiguous, birth-synchronised stream
+ /// must publish NOTHING. Without this the rebirth assertions could pass off a driver that
+ /// NCMDs on every single message.
+ ///
+ [Fact]
+ public async Task ContiguousStream_RequestsNoRebirth()
+ {
+ var publish = new RecordingPublishTransport();
+ var ing = await NewSubscribedIngestorAsync(publish);
+
+ FeedBirths(ing);
+ ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 2, (TempAlias, TahuDataType.Float, 1f)));
+ ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 3, (TempAlias, TahuDataType.Float, 2f)));
+ await ing.DrainPendingRebirthsAsync();
+
+ publish.Topics.ShouldBeEmpty();
+ }
+
+ ///
+ /// 255 → 0 is the sequence continuing, not a gap. Reading the wrap as a gap asks every edge node
+ /// to rebirth once per 256 messages, so a busy node spends its life republishing births.
+ ///
+ [Fact]
+ public async Task SeqWrapsFrom255To0_IsNotAGap()
+ {
+ var publish = new RecordingPublishTransport();
+ var ing = await NewSubscribedIngestorAsync(publish);
+
+ ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 255, bdSeq: 1));
+ ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Dbirth(seq: 0));
+ ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 1, (TempAlias, TahuDataType.Float, 1f)));
+ await ing.DrainPendingRebirthsAsync();
+
+ publish.Topics.ShouldBeEmpty();
+ }
+
+ ///
+ /// A data message whose sequence happens to be contiguous but which arrived with no NBIRTH seen
+ /// since connect — the driver joined mid-stream and holds no alias table.
+ /// returns for it, so a driver that
+ /// only read the return value would sit happily behind a stream it cannot decode.
+ ///
+ [Fact]
+ public async Task LateJoin_FirstMessageIsNotABirth_RequestsRebirth()
+ {
+ var publish = new RecordingPublishTransport();
+ var ing = await NewSubscribedIngestorAsync(publish);
+
+ ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Dbirth(seq: 17));
+ await ing.DrainPendingRebirthsAsync();
+
+ publish.Topics.ShouldContain("spBv1.0/Plant1/NCMD/EdgeA");
+ }
+
+ ///
+ /// Repeatedly commanding a node that is already republishing IS the storm the design warns
+ /// about. Three unroutable messages in a row must produce one NCMD, not three.
+ ///
+ [Fact]
+ public async Task RepeatedRebirthTriggers_AreDebouncedPerEdgeNode()
+ {
+ var publish = new RecordingPublishTransport();
+ var ing = await NewSubscribedIngestorAsync(publish, rebirthDebounce: TimeSpan.FromMinutes(5));
+
+ for (var i = 0; i < 3; i++)
+ {
+ ing.Dispatch(
+ Topic(SparkplugMessageType.DDATA, Device),
+ Ddata(seq: (ulong)i, (CountAlias, TahuDataType.Float, 1f)));
+ }
+
+ await ing.DrainPendingRebirthsAsync();
+
+ publish.Topics.Count.ShouldBe(1);
+ }
+
+ /// The debounce floor is per edge node, not global — a second node must not be starved.
+ [Fact]
+ public async Task RebirthDebounce_IsPerEdgeNode()
+ {
+ var publish = new RecordingPublishTransport();
+ var ing = await NewSubscribedIngestorAsync(
+ publish,
+ rebirthDebounce: TimeSpan.FromMinutes(5),
+ extraTags: [Tag("Plant/Mqtt/spb/EdgeBTemp", Blob(Group, "EdgeB", Device, "Temperature", "Float32"))]);
+
+ ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 0, (CountAlias, TahuDataType.Float, 1f)));
+ ing.Dispatch(
+ new SparkplugTopic(SparkplugMessageType.DDATA, Group, "EdgeB", Device, null),
+ Ddata(seq: 0, (CountAlias, TahuDataType.Float, 1f)));
+ await ing.DrainPendingRebirthsAsync();
+
+ publish.Topics.ShouldContain("spBv1.0/Plant1/NCMD/EdgeA");
+ publish.Topics.ShouldContain("spBv1.0/Plant1/NCMD/EdgeB");
+ }
+
+ /// requestRebirthOnGap = false is the operator's off switch for ALL outbound NCMDs.
+ [Fact]
+ public async Task RequestRebirthOnGapDisabled_PublishesNothing()
+ {
+ var publish = new RecordingPublishTransport();
+ var ing = await NewSubscribedIngestorAsync(publish, requestRebirthOnGap: false);
+
+ ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 0, (CountAlias, TahuDataType.Float, 1f)));
+ await ing.DrainPendingRebirthsAsync();
+
+ publish.Topics.ShouldBeEmpty();
+ }
+
+ /// A missing publish transport degrades to a log, never an exception on the dispatcher.
+ [Fact]
+ public async Task RebirthWithNoPublishTransport_DoesNotThrow()
+ {
+ var ing = await NewSubscribedIngestorAsync(publish: null);
+
+ Should.NotThrow(() => ing.Dispatch(
+ Topic(SparkplugMessageType.DDATA, Device),
+ Ddata(seq: 0, (CountAlias, TahuDataType.Float, 1f))));
+ }
+
+ // ---------------------------------------------------------------------------------
+ // §3.6 #4 — death → STALE, next birth restores Good
+ // ---------------------------------------------------------------------------------
+
+ /// The plan's third snippet.
+ [Fact]
+ public async Task Ddeath_EmitsBadForDeviceMetrics()
+ {
+ var ing = await NewSubscribedIngestorAsync();
+ FeedBirths(ing);
+
+ var quals = new List();
+ ing.OnDataChange += (_, e) => quals.Add(e.Snapshot.StatusCode);
+
+ ing.Dispatch(Topic(SparkplugMessageType.DDEATH, Device), Ddeath(seq: 2));
+
+ quals.ShouldNotBeEmpty();
+ quals.ShouldAllBe(q => q == BadCommunicationError);
+ }
+
+ /// The completing half of §3.6 #4 — a birth is a full snapshot, so it restores Good.
+ [Fact]
+ public async Task DdeathThenDbirth_RestoresGood()
+ {
+ var ing = await NewSubscribedIngestorAsync();
+ FeedBirths(ing);
+
+ var last = new Dictionary(StringComparer.Ordinal);
+ ing.OnDataChange += (_, e) => last[e.FullReference] = e.Snapshot;
+
+ ing.Dispatch(Topic(SparkplugMessageType.DDEATH, Device), Ddeath(seq: 2));
+ last[TempPath].StatusCode.ShouldBe(BadCommunicationError);
+
+ ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Dbirth(seq: 3));
+
+ last[TempPath].StatusCode.ShouldBe(Good);
+ last[TempPath].Value.ShouldBe(0f);
+ }
+
+ /// A DDEATH stales exactly its own device; the node's own metrics keep flowing.
+ [Fact]
+ public async Task Ddeath_DoesNotStaleTheNodesOwnMetrics()
+ {
+ var ing = await NewSubscribedIngestorAsync();
+ FeedBirths(ing);
+
+ var last = new Dictionary(StringComparer.Ordinal);
+ ing.OnDataChange += (_, e) => last[e.FullReference] = e.Snapshot;
+
+ ing.Dispatch(Topic(SparkplugMessageType.DDEATH, Device), Ddeath(seq: 2));
+
+ last.ShouldContainKey(TempPath);
+ last.ShouldNotContainKey(NodeUptimePath);
+ }
+
+ /// An NDEATH kills the node AND every device under it.
+ [Fact]
+ public async Task Ndeath_StalesTheNodeAndEveryDeviceUnderIt()
+ {
+ var ing = await NewSubscribedIngestorAsync();
+ FeedBirths(ing);
+
+ var last = new Dictionary(StringComparer.Ordinal);
+ ing.OnDataChange += (_, e) => last[e.FullReference] = e.Snapshot;
+
+ ing.Dispatch(Topic(SparkplugMessageType.NDEATH), Ndeath(bdSeq: 1));
+
+ last[TempPath].StatusCode.ShouldBe(BadCommunicationError);
+ last[NodeUptimePath].StatusCode.ShouldBe(BadCommunicationError);
+ }
+
+ ///
+ /// The bdSeq tie of §3.6 #3. A node's connection drops, it reconnects and re-births, and
+ /// only THEN does the broker deliver the old session's Last Will. Acting on it marks a live node
+ /// dead with nothing coming to correct it.
+ ///
+ [Fact]
+ public async Task StaleNdeath_FromAPreviousSession_IsIgnored()
+ {
+ var ing = await NewSubscribedIngestorAsync();
+
+ ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 0, bdSeq: 2));
+ ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Dbirth(seq: 1));
+
+ var quals = new List();
+ ing.OnDataChange += (_, e) => quals.Add(e.Snapshot.StatusCode);
+
+ ing.Dispatch(Topic(SparkplugMessageType.NDEATH), Ndeath(bdSeq: 1));
+
+ quals.ShouldBeEmpty();
+ ing.Births.Find(new SparkplugScope(Group, Node, Device)).ShouldNotBeNull();
+ }
+
+ ///
+ /// NDEATH must never reach . It is the broker-published
+ /// Last Will and carries no seq at all, so Accept would report a gap on every death
+ /// and answer a node that has just died with a rebirth request it can never serve.
+ ///
+ [Fact]
+ public async Task Ndeath_DoesNotAdvanceTheSequenceTracker_AndRequestsNoRebirth()
+ {
+ var publish = new RecordingPublishTransport();
+ var ing = await NewSubscribedIngestorAsync(publish);
+
+ FeedBirths(ing);
+ publish.Topics.Clear();
+
+ ing.Dispatch(Topic(SparkplugMessageType.NDEATH), Ndeath(bdSeq: 1));
+ await ing.DrainPendingRebirthsAsync();
+
+ publish.Topics.ShouldBeEmpty();
+ }
+
+ ///
+ /// Per the Sparkplug spec an NBIRTH voids every prior DBIRTH under that node: those devices MUST
+ /// re-DBIRTH, and until they do their aliases mean nothing.
+ ///
+ [Fact]
+ public async Task Nbirth_InvalidatesPriorDeviceBirths_AndStalesThem()
+ {
+ var ing = await NewSubscribedIngestorAsync();
+ FeedBirths(ing);
+
+ var last = new Dictionary(StringComparer.Ordinal);
+ ing.OnDataChange += (_, e) => last[e.FullReference] = e.Snapshot;
+
+ ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 0, bdSeq: 2));
+
+ last[TempPath].StatusCode.ShouldBe(BadCommunicationError);
+ ing.Births.Find(new SparkplugScope(Group, Node, Device)).ShouldBeNull();
+ }
+
+ // ---------------------------------------------------------------------------------
+ // Wire-level value correctness
+ // ---------------------------------------------------------------------------------
+
+ ///
+ /// Sparkplug carries Int8/16/32/64 as two's complement in an unsigned proto field, so a
+ /// metric whose value is -43 arrives as 4294967253. Skipping
+ /// publishes a plausible, Good-quality,
+ /// completely wrong number — or, once the tag is Int32, an out-of-range refusal.
+ ///
+ [Fact]
+ public async Task NegativeInteger_IsReinterpretedFromItsTwosComplementWireValue()
+ {
+ var ing = await NewSubscribedIngestorAsync();
+ FeedBirths(ing);
+
+ var seen = new Dictionary(StringComparer.Ordinal);
+ ing.OnDataChange += (_, e) => seen[e.FullReference] = e.Snapshot;
+
+ ing.Dispatch(
+ Topic(SparkplugMessageType.DDATA, Device),
+ Ddata(seq: 2, (CountAlias, TahuDataType.Int32, -43)));
+
+ seen[CountPath].StatusCode.ShouldBe(Good);
+ seen[CountPath].Value.ShouldBe(-43);
+ seen[CountPath].Value.ShouldNotBe(4294967253L);
+ }
+
+ /// The birth's own values are published too — that is how a rebirth restores Good.
+ [Fact]
+ public async Task BirthValues_ArePublished()
+ {
+ var ing = await NewSubscribedIngestorAsync();
+ var seen = new Dictionary(StringComparer.Ordinal);
+ ing.OnDataChange += (_, e) => seen[e.FullReference] = e.Snapshot.Value;
+
+ FeedBirths(ing);
+
+ seen[TempPath].ShouldBe(0f);
+ seen[CountPath].ShouldBe(-42);
+ }
+
+ ///
+ /// A tag authored with no dataType takes the type its BIRTH declared — the reason
+ /// dataType is optional in the Sparkplug tag shape at all.
+ ///
+ [Fact]
+ public async Task TagWithNoAuthoredDataType_TakesTheBirthsType()
+ {
+ const string Untyped = "Plant/Mqtt/spb/Untyped";
+ var ing = NewIngestor(tags: [Tag(Untyped, """{"groupId":"Plant1","edgeNodeId":"EdgeA","deviceId":"Filler1","metricName":"Temperature"}""")]);
+ await ing.SubscribeAsync([Untyped], TimeSpan.Zero, TestContext.Current.CancellationToken);
+
+ object? value = null;
+ ing.OnDataChange += (_, e) => value = e.Snapshot.Value;
+
+ ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 0, bdSeq: 1));
+ ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Dbirth(seq: 1));
+ ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 2, (TempAlias, TahuDataType.Float, 7.5f)));
+
+ // Float32, from the birth — NOT the record's String default, which would have produced "7.5".
+ value.ShouldBe(7.5f);
+ }
+
+ /// An authored dataType wins over the birth's — the operator declared it deliberately.
+ [Fact]
+ public async Task AuthoredDataType_WinsOverTheBirths()
+ {
+ const string AsText = "Plant/Mqtt/spb/TempAsText";
+ var ing = NewIngestor(tags: [Tag(AsText, Blob(Group, Node, Device, "Temperature", "String"))]);
+ await ing.SubscribeAsync([AsText], TimeSpan.Zero, TestContext.Current.CancellationToken);
+
+ object? value = null;
+ ing.OnDataChange += (_, e) => value = e.Snapshot.Value;
+
+ ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 0, bdSeq: 1));
+ ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Dbirth(seq: 1));
+ ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 2, (TempAlias, TahuDataType.Float, 7.5f)));
+
+ value.ShouldBe("7.5");
+ }
+
+ /// An explicit Sparkplug is_null is not a value of the tag's declared type.
+ [Fact]
+ public async Task ExplicitNullMetric_PublishesBadTypeMismatch()
+ {
+ var ing = await NewSubscribedIngestorAsync();
+ FeedBirths(ing);
+
+ var seen = new Dictionary(StringComparer.Ordinal);
+ ing.OnDataChange += (_, e) => seen[e.FullReference] = e.Snapshot;
+
+ var payload = new Payload { Seq = 2UL, Timestamp = 1UL };
+ payload.Metrics.Add(new Payload.Types.Metric { Alias = TempAlias, IsNull = true });
+ ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Decode(payload));
+
+ seen[TempPath].StatusCode.ShouldBe(BadTypeMismatch);
+ seen[TempPath].Value.ShouldBeNull();
+ }
+
+ ///
+ /// A DataSet/Template metric is warn-and-skip — NOT a missing metric and NOT a rebirth trigger.
+ /// A node asked to rebirth would answer with the same unsupported metric and the pair would loop.
+ ///
+ [Fact]
+ public async Task UnsupportedMetricKind_IsSkipped_AndRequestsNoRebirth()
+ {
+ var publish = new RecordingPublishTransport();
+ var ing = await NewSubscribedIngestorAsync(publish);
+ FeedBirths(ing);
+ publish.Topics.Clear();
+
+ var seen = new List();
+ ing.OnDataChange += (_, e) => seen.Add(e.FullReference);
+
+ var payload = new Payload { Seq = 2UL, Timestamp = 1UL };
+ payload.Metrics.Add(new Payload.Types.Metric { Alias = TempAlias, DatasetValue = new Payload.Types.DataSet() });
+ ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Decode(payload));
+ await ing.DrainPendingRebirthsAsync();
+
+ seen.ShouldBeEmpty();
+ publish.Topics.ShouldBeEmpty();
+ }
+
+ /// A value that does not fit the authored type is refused, never rounded or wrapped.
+ [Fact]
+ public async Task FractionalValueForAnIntegerTag_PublishesBadTypeMismatch()
+ {
+ const string IntTag = "Plant/Mqtt/spb/TempAsInt";
+ var ing = NewIngestor(tags: [Tag(IntTag, Blob(Group, Node, Device, "Temperature", "Int32"))]);
+ await ing.SubscribeAsync([IntTag], TimeSpan.Zero, TestContext.Current.CancellationToken);
+
+ DataValueSnapshot? snapshot = null;
+ ing.OnDataChange += (_, e) => snapshot = e.Snapshot;
+
+ ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 0, bdSeq: 1));
+ ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Dbirth(seq: 1));
+ ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 2, (TempAlias, TahuDataType.Float, 21.5f)));
+
+ snapshot!.StatusCode.ShouldBe(BadTypeMismatch);
+ }
+
+ // ---------------------------------------------------------------------------------
+ // Robustness: nothing may throw or mutate state off bad input
+ // ---------------------------------------------------------------------------------
+
+ ///
+ /// An undecodable body is not evidence of anything. It must not clear an alias table, advance a
+ /// sequence tracker, or evict a birth — a hostile publisher on a group-wide # subscription
+ /// would otherwise be able to blank the address space with garbage bytes.
+ ///
+ [Fact]
+ public async Task InvalidPayload_MutatesNothing()
+ {
+ var publish = new RecordingPublishTransport();
+ var ing = await NewSubscribedIngestorAsync(publish);
+ FeedBirths(ing);
+ publish.Topics.Clear();
+
+ ing.HandleMessage("spBv1.0/Plant1/DDATA/EdgeA/Filler1", [0xDE, 0xAD, 0xBE, 0xEF], retained: false);
+ await ing.DrainPendingRebirthsAsync();
+
+ // The birth survived …
+ ing.Births.Find(new SparkplugScope(Group, Node, Device)).ShouldNotBeNull();
+
+ // … the sequence baseline survived (seq 2 is still contiguous, so no rebirth) …
+ var seen = new Dictionary(StringComparer.Ordinal);
+ ing.OnDataChange += (_, e) => seen[e.FullReference] = e.Snapshot.Value;
+ ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 2, (TempAlias, TahuDataType.Float, 9f)));
+ await ing.DrainPendingRebirthsAsync();
+
+ seen[TempPath].ShouldBe(9f);
+ publish.Topics.ShouldBeEmpty();
+ }
+
+ /// An undecodable NDEATH must not kill a live node either.
+ [Fact]
+ public async Task InvalidNdeathPayload_DoesNotStale()
+ {
+ var ing = await NewSubscribedIngestorAsync();
+ FeedBirths(ing);
+
+ var quals = new List();
+ ing.OnDataChange += (_, e) => quals.Add(e.Snapshot.StatusCode);
+
+ ing.HandleMessage("spBv1.0/Plant1/NDEATH/EdgeA", [0x01, 0x02, 0x03], retained: false);
+
+ quals.ShouldBeEmpty();
+ }
+
+ ///
+ /// Runs on MQTTnet's dispatcher thread: an escaping exception stalls delivery for every
+ /// subscription on the connection.
+ ///
+ [Theory]
+ [InlineData("")]
+ [InlineData("not/a/sparkplug/topic")]
+ [InlineData("spBv1.0/Plant1/NBIRTH/EdgeA")]
+ [InlineData("spBv1.0/Plant1/DDATA/EdgeA/Filler1")]
+ [InlineData("spBv1.0/STATE/otopcua-host-1")]
+ [InlineData("spBv1.0")]
+ public async Task HandleMessage_NeverThrows(string topic)
+ {
+ var ing = await NewSubscribedIngestorAsync();
+
+ Should.NotThrow(() => ing.HandleMessage(topic, [], retained: false));
+ Should.NotThrow(() => ing.HandleMessage(topic, [0xFF, 0x00, 0x7F], retained: true));
+ }
+
+ /// A throwing subscriber is contained rather than escaping onto the dispatcher thread.
+ [Fact]
+ public async Task ThrowingSubscriber_IsContained()
+ {
+ var ing = await NewSubscribedIngestorAsync();
+ ing.OnDataChange += (_, _) => throw new InvalidOperationException("boom");
+
+ Should.NotThrow(() => FeedBirths(ing));
+ }
+
+ /// An oversize body is refused BEFORE any protobuf parse, and mutates nothing.
+ [Fact]
+ public async Task OversizePayload_IsDroppedBeforeDecoding()
+ {
+ var ing = NewIngestor(maxPayloadBytes: 16);
+ await ing.SubscribeAsync([TempPath], TimeSpan.Zero, TestContext.Current.CancellationToken);
+
+ var bytes = Nbirth(seq: 0, bdSeq: 1);
+ bytes.Metrics.Count.ShouldBeGreaterThan(0);
+
+ ing.HandleMessage("spBv1.0/Plant1/NBIRTH/EdgeA", new byte[64], retained: false);
+
+ ing.Births.Count.ShouldBe(0);
+ }
+
+ ///
+ /// The driver subscribes spBv1.0/{group}/#, so a large plant delivers traffic for every
+ /// node in the group. Only authored edge nodes are processed — that is what bounds the tracker
+ /// and birth maps by configuration rather than by whatever the plant publishes.
+ ///
+ [Fact]
+ public async Task UnauthoredEdgeNode_IsIgnoredEntirely()
+ {
+ var publish = new RecordingPublishTransport();
+ var ing = await NewSubscribedIngestorAsync(publish);
+
+ var fired = false;
+ ing.OnDataChange += (_, _) => fired = true;
+
+ ing.Dispatch(new SparkplugTopic(SparkplugMessageType.NBIRTH, Group, "StrangerNode", null, null), Nbirth(seq: 0, bdSeq: 1));
+ ing.Dispatch(
+ new SparkplugTopic(SparkplugMessageType.DDATA, Group, "StrangerNode", "Dev", null),
+ Ddata(seq: 1, (TempAlias, TahuDataType.Float, 1f)));
+ await ing.DrainPendingRebirthsAsync();
+
+ fired.ShouldBeFalse();
+ ing.Births.Count.ShouldBe(0);
+ publish.Topics.ShouldBeEmpty();
+ }
+
+ ///
+ /// An unauthored DEVICE under an authored node still advances its edge node's sequence — dropping
+ /// it early would manufacture a gap on the very next authored message — and, because nothing in
+ /// that scope could ever be published, it must not demand a rebirth either. A spec-violating
+ /// device that publishes DDATA without ever DBIRTHing would otherwise drive a permanent NCMD at
+ /// the debounce cadence, forever.
+ ///
+ [Fact]
+ public async Task UnauthoredDeviceUnderAnAuthoredNode_StillAdvancesTheSequence()
+ {
+ var publish = new RecordingPublishTransport();
+ var ing = await NewSubscribedIngestorAsync(publish);
+ FeedBirths(ing);
+ publish.Topics.Clear();
+
+ // seq 2 belongs to a device nobody authored …
+ ing.Dispatch(
+ new SparkplugTopic(SparkplugMessageType.DDATA, Group, Node, "OtherDevice", null),
+ Ddata(seq: 2, (TempAlias, TahuDataType.Float, 1f)));
+
+ // … and seq 3 must therefore still be contiguous.
+ ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 3, (TempAlias, TahuDataType.Float, 2f)));
+ await ing.DrainPendingRebirthsAsync();
+
+ publish.Topics.ShouldBeEmpty();
+ }
+
+ // ---------------------------------------------------------------------------------
+ // Registration / resolution / read
+ // ---------------------------------------------------------------------------------
+
+ [Fact]
+ public void Register_MapsOnlySparkplugShapedBlobs()
+ {
+ var ing = NewIngestor(tags: []);
+
+ var mapped = ing.Register([
+ Tag(TempPath, Blob(Group, Node, Device, "Temperature", "Float32")),
+ Tag("Plant/Mqtt/spb/Plainish", """{"topic":"f/oven/temp","payloadFormat":"Scalar","dataType":"Float64"}"""),
+ Tag("Plant/Mqtt/spb/NoMetric", """{"groupId":"Plant1","edgeNodeId":"EdgeA"}"""),
+ ]);
+
+ mapped.ShouldBe(1);
+ ing.TryResolve(TempPath, out var def).ShouldBeTrue();
+ def.MetricName.ShouldBe("Temperature");
+ def.Name.ShouldBe(TempPath);
+ }
+
+ [Fact]
+ public void Register_IsWholesale_ADroppedTagStopsResolving()
+ {
+ var ing = NewIngestor();
+ ing.TryResolve(TempPath, out _).ShouldBeTrue();
+
+ ing.Register([]);
+
+ ing.TryResolve(TempPath, out _).ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task SubscribeAsync_UnauthoredReference_ReportsBadNodeIdUnknown()
+ {
+ var ing = NewIngestor();
+
+ await ing.SubscribeAsync(["Plant/Mqtt/spb/NotAThing"], TimeSpan.Zero, TestContext.Current.CancellationToken);
+
+ ing.Values.Read("Plant/Mqtt/spb/NotAThing").StatusCode.ShouldBe(BadNodeIdUnknown);
+ }
+
+ [Fact]
+ public async Task UnsubscribedButAuthoredTag_IsCachedButNotRaised()
+ {
+ var ing = NewIngestor();
+ var fired = false;
+ ing.OnDataChange += (_, _) => fired = true;
+
+ FeedBirths(ing);
+
+ fired.ShouldBeFalse();
+ ing.Values.Read(TempPath).Value.ShouldBe(0f);
+ }
+
+ /// Two raw tags may legitimately project the same Sparkplug metric; both must be fed.
+ [Fact]
+ public async Task TwoTagsBoundToOneMetric_BothReceive()
+ {
+ const string Mirror = "Plant/Mqtt/spb/Filler1TempMirror";
+ var ing = NewIngestor(extraTags: [Tag(Mirror, Blob(Group, Node, Device, "Temperature", "Float32"))]);
+ await ing.SubscribeAsync([TempPath, Mirror], TimeSpan.Zero, TestContext.Current.CancellationToken);
+
+ var seen = new List();
+ ing.OnDataChange += (_, e) => seen.Add(e.FullReference);
+
+ FeedBirths(ing);
+ ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 2, (TempAlias, TahuDataType.Float, 1f)));
+
+ seen.ShouldContain(TempPath);
+ seen.ShouldContain(Mirror);
+ }
+
+ // ---------------------------------------------------------------------------------
+ // Subscribe / reconnect
+ // ---------------------------------------------------------------------------------
+
+ [Fact]
+ public async Task EstablishAsync_SubscribesTheGroupAndStateFilters_AndRequestsLateJoinRebirth()
+ {
+ var subscribe = new FakeSubscribeTransport();
+ var publish = new RecordingPublishTransport();
+ var ing = NewIngestor(publish, subscribe, hostId: "otopcua-host-1");
+
+ await ing.EstablishAsync(TestContext.Current.CancellationToken);
+ await ing.DrainPendingRebirthsAsync();
+
+ subscribe.Filters.Select(f => f.Topic).ShouldBe(["spBv1.0/Plant1/#", "spBv1.0/STATE/otopcua-host-1"]);
+ publish.Topics.ShouldContain("spBv1.0/Plant1/NCMD/EdgeA");
+ }
+
+ /// No host id ⇒ no STATE filter. This driver has no use for a stranger's host state.
+ [Fact]
+ public async Task EstablishAsync_WithNoHostId_SubscribesTheGroupFilterOnly()
+ {
+ var subscribe = new FakeSubscribeTransport();
+ var ing = NewIngestor(subscribe: subscribe, hostId: "");
+
+ await ing.EstablishAsync(TestContext.Current.CancellationToken);
+
+ subscribe.Filters.Select(f => f.Topic).ShouldBe(["spBv1.0/Plant1/#"]);
+ }
+
+ ///
+ /// There is exactly one filter and it covers every tag, so a broker that refuses it leaves the
+ /// whole driver deaf — the one outcome that must be a visible fault rather than a healthy-looking
+ /// driver receiving nothing.
+ ///
+ [Fact]
+ public async Task EstablishAsync_GroupFilterRefused_Throws()
+ {
+ var subscribe = new FakeSubscribeTransport { Reject = _ => true };
+ var ing = NewIngestor(subscribe: subscribe);
+
+ await Should.ThrowAsync(
+ () => ing.EstablishAsync(TestContext.Current.CancellationToken));
+ }
+
+ /// A refused STATE filter costs an observability signal, not the data plane.
+ [Fact]
+ public async Task EstablishAsync_StateFilterRefused_DoesNotThrow()
+ {
+ var subscribe = new FakeSubscribeTransport { Reject = t => t.Contains("STATE", StringComparison.Ordinal) };
+ var ing = NewIngestor(subscribe: subscribe, hostId: "otopcua-host-1");
+
+ await ing.EstablishAsync(TestContext.Current.CancellationToken);
+ }
+
+ ///
+ /// Across an outage of unknown length an edge node may have restarted and rebound its aliases; a
+ /// DDATA resolved against the pre-outage table would route a value to whichever tag used to own
+ /// that alias.
+ ///
+ [Fact]
+ public async Task OnReconnected_ClearsBirths_ResubscribesAndRequestsRebirth()
+ {
+ var subscribe = new FakeSubscribeTransport();
+ var publish = new RecordingPublishTransport();
+ var ing = NewIngestor(publish, subscribe);
+ await ing.SubscribeAsync([TempPath], TimeSpan.Zero, TestContext.Current.CancellationToken);
+
+ FeedBirths(ing);
+ ing.Births.Count.ShouldBeGreaterThan(0);
+ publish.Topics.Clear();
+ subscribe.Filters.Clear();
+
+ await ing.OnReconnectedAsync(TestContext.Current.CancellationToken);
+ await ing.DrainPendingRebirthsAsync();
+
+ ing.Births.Count.ShouldBe(0);
+ subscribe.Filters.Select(f => f.Topic).ShouldContain("spBv1.0/Plant1/#");
+ publish.Topics.ShouldContain("spBv1.0/Plant1/NCMD/EdgeA");
+ }
+
+ ///
+ /// Reconnect is a driver-side event, not a statement about the plant: last-known values remain
+ /// the best available answer until the rebirth lands. Only a death stales a tag.
+ ///
+ [Fact]
+ public async Task OnReconnected_DoesNotStaleValues()
+ {
+ var ing = await NewSubscribedIngestorAsync(new RecordingPublishTransport(), new FakeSubscribeTransport());
+ FeedBirths(ing);
+
+ await ing.OnReconnectedAsync(TestContext.Current.CancellationToken);
+
+ ing.Values.Read(TempPath).StatusCode.ShouldBe(Good);
+ }
+
+ /// After a reconnect the driver has no birth, so the first DDATA must ask for one.
+ [Fact]
+ public async Task AfterReconnect_DataResolvesAgainstNothing_AndRequestsRebirth()
+ {
+ var publish = new RecordingPublishTransport();
+ var ing = await NewSubscribedIngestorAsync(publish, new FakeSubscribeTransport(), rebirthDebounce: TimeSpan.Zero);
+ FeedBirths(ing);
+
+ await ing.OnReconnectedAsync(TestContext.Current.CancellationToken);
+ publish.Topics.Clear();
+
+ var fired = false;
+ ing.OnDataChange += (_, _) => fired = true;
+ ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 2, (TempAlias, TahuDataType.Float, 1f)));
+ await ing.DrainPendingRebirthsAsync();
+
+ fired.ShouldBeFalse();
+ publish.Topics.ShouldContain("spBv1.0/Plant1/NCMD/EdgeA");
+ }
+
+ // ---------------------------------------------------------------------------------
+ // STATE (receive only — this driver never publishes one; see the ingestor's remarks)
+ // ---------------------------------------------------------------------------------
+
+ [Fact]
+ public async Task State_V30JsonPayload_IsObserved()
+ {
+ var ing = await NewSubscribedIngestorAsync();
+
+ ing.HandleMessage("spBv1.0/STATE/otopcua-host-1", """{"online":true,"timestamp":1721822400000}"""u8, retained: true);
+
+ ing.HostState!.HostId.ShouldBe("otopcua-host-1");
+ ing.HostState!.Online.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task State_LegacyTextPayload_IsObserved()
+ {
+ var ing = await NewSubscribedIngestorAsync();
+
+ ing.HandleMessage("STATE/otopcua-host-1", "OFFLINE"u8, retained: true);
+
+ ing.HostState!.Online.ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task State_UnrecognisedPayload_IsIgnored()
+ {
+ var ing = await NewSubscribedIngestorAsync();
+
+ ing.HandleMessage("spBv1.0/STATE/otopcua-host-1", "maybe?"u8, retained: true);
+
+ ing.HostState.ShouldBeNull();
+ }
+
+ // ---------------------------------------------------------------------------------
+ // Task 22 seam
+ // ---------------------------------------------------------------------------------
+
+ ///
+ /// The rediscovery seam Task 22 wires to IRediscoverable.OnRediscoveryNeeded. Deliberately
+ /// unwired in MqttDriver today — this asserts the event exists and carries the scope +
+ /// metric set that Task 22 needs to decide whether the tree changed.
+ ///
+ [Fact]
+ public async Task BirthObserved_FiresWithTheScopeAndMetricNames()
+ {
+ var ing = await NewSubscribedIngestorAsync();
+ var observed = new List();
+ ing.BirthObserved += (_, e) => observed.Add(e);
+
+ FeedBirths(ing);
+
+ observed.Count.ShouldBe(2);
+ observed[0].Scope.ShouldBe(new SparkplugScope(Group, Node, null));
+ observed[1].Scope.ShouldBe(new SparkplugScope(Group, Node, Device));
+ observed[1].MetricNames.ShouldContain("Temperature");
+ }
+
+ // =================================================================================
+ // Fixtures
+ // =================================================================================
+
+ private static RawTagEntry Tag(string rawPath, string tagConfig) => new(rawPath, tagConfig, WriteIdempotent: false);
+
+ private static string Blob(string group, string node, string? device, string metric, string? dataType)
+ {
+ var deviceKey = device is null ? "" : $"""{'"'}deviceId{'"'}:"{device}",""";
+ var typeKey = dataType is null ? "" : $""","dataType":"{dataType}" """;
+ return $$"""{"groupId":"{{group}}","edgeNodeId":"{{node}}",{{deviceKey}}"metricName":"{{metric}}"{{typeKey}}}""";
+ }
+
+ /// The standard authored set: three device metrics plus one node-level metric.
+ private static RawTagEntry[] DefaultTags() =>
+ [
+ Tag(TempPath, Blob(Group, Node, Device, "Temperature", "Float32")),
+ Tag(PressPath, Blob(Group, Node, Device, "Pressure", "Float32")),
+ Tag(CountPath, Blob(Group, Node, Device, "Count", "Int32")),
+ Tag(NodeUptimePath, Blob(Group, Node, null, "Uptime", "Int64")),
+ ];
+
+ private static SparkplugIngestor NewIngestor(
+ RecordingPublishTransport? publish = null,
+ FakeSubscribeTransport? subscribe = null,
+ string hostId = "otopcua-host-1",
+ bool requestRebirthOnGap = true,
+ TimeSpan? rebirthDebounce = null,
+ int maxPayloadBytes = MqttSubscriptionManager.DefaultMaxPayloadBytes,
+ RawTagEntry[]? tags = null,
+ RawTagEntry[]? extraTags = null)
+ {
+ var options = new MqttDriverOptions
+ {
+ Mode = MqttMode.SparkplugB,
+ Sparkplug = new MqttSparkplugOptions
+ {
+ GroupId = Group,
+ HostId = hostId,
+ RequestRebirthOnGap = requestRebirthOnGap,
+ },
+ };
+
+ var ingestor = new SparkplugIngestor(
+ options,
+ "mqtt-spb-1",
+ subscribe,
+ publish,
+ logger: null,
+ cache: null,
+ maxPayloadBytes: maxPayloadBytes,
+ // Zero by default so a test asserting "these two triggers each produce an NCMD" is not
+ // silently satisfied by the production debounce; the debounce itself has its own tests.
+ rebirthDebounce: rebirthDebounce ?? TimeSpan.Zero);
+
+ ingestor.Register([.. tags ?? DefaultTags(), .. extraTags ?? []]);
+ return ingestor;
+ }
+
+ /// An ingestor with every default tag registered AND subscribed, so notifications fire.
+ private static async Task NewSubscribedIngestorAsync(
+ RecordingPublishTransport? publish = null,
+ FakeSubscribeTransport? subscribe = null,
+ bool requestRebirthOnGap = true,
+ TimeSpan? rebirthDebounce = null,
+ RawTagEntry[]? extraTags = null)
+ {
+ var ingestor = NewIngestor(
+ publish,
+ subscribe,
+ requestRebirthOnGap: requestRebirthOnGap,
+ rebirthDebounce: rebirthDebounce,
+ extraTags: extraTags);
+
+ await ingestor.SubscribeAsync(
+ [.. ingestor.AuthoredRawPaths],
+ TimeSpan.Zero,
+ TestContext.Current.CancellationToken);
+
+ return ingestor;
+ }
+
+ /// Feeds the standard NBIRTH (seq 0, bdSeq 1) + DBIRTH (seq 1) pair.
+ private static void FeedBirths(SparkplugIngestor ing)
+ {
+ ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 0, bdSeq: 1));
+ ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Dbirth(seq: 1));
+ }
+
+ 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());
+
+ /// An NBIRTH: the bdSeq session token plus the node's own metric catalog.
+ private static SparkplugPayload Nbirth(ulong seq, ulong bdSeq)
+ {
+ var payload = new Payload { Seq = seq, Timestamp = 1721822400000UL };
+ payload.Metrics.Add(new Payload.Types.Metric
+ {
+ Name = "bdSeq",
+ Datatype = (uint)TahuDataType.Uint64,
+ LongValue = bdSeq,
+ });
+ payload.Metrics.Add(BirthMetric("Uptime", 7UL, TahuDataType.Int64, 0L));
+ return Decode(payload);
+ }
+
+ /// The standard DBIRTH: Temperature@5 (Float), Pressure@6 (Float), Count@9 (Int32, -42).
+ private static SparkplugPayload Dbirth(ulong seq) => DbirthCustom(
+ seq,
+ ("Temperature", TempAlias, TahuDataType.Float, 0f),
+ ("Pressure", PressAlias, TahuDataType.Float, 0f),
+ ("Count", CountAlias, TahuDataType.Int32, -42));
+
+ private static SparkplugPayload DbirthCustom(
+ ulong seq,
+ params (string Name, ulong Alias, TahuDataType Type, object Value)[] metrics)
+ {
+ var payload = new Payload { Seq = seq, Timestamp = 1721822400000UL };
+ foreach (var (name, alias, type, value) in metrics)
+ {
+ payload.Metrics.Add(BirthMetric(name, alias, type, value));
+ }
+
+ return Decode(payload);
+ }
+
+ /// A DDATA: metrics carry an alias and NOTHING else — the real post-birth wire shape.
+ private static SparkplugPayload Ddata(ulong seq, params (ulong Alias, TahuDataType Type, object Value)[] metrics)
+ => DataPayload(seq, metrics);
+
+ /// An NDATA — identical shape to ; the topic is what makes it node-scoped.
+ private static SparkplugPayload Ndata(ulong seq, params (ulong Alias, TahuDataType Type, object Value)[] metrics)
+ => DataPayload(seq, metrics);
+
+ private static SparkplugPayload DataPayload(
+ ulong seq,
+ (ulong Alias, TahuDataType Type, object Value)[] metrics)
+ {
+ var payload = new Payload { Seq = seq, Timestamp = 1721822405000UL };
+ foreach (var (alias, type, value) in metrics)
+ {
+ // Deliberately NO Name and NO Datatype: that is what a Sparkplug DATA metric looks like
+ // after a birth, and it is the case an "absent means empty string" consumer gets wrong.
+ var metric = new Payload.Types.Metric { Alias = alias };
+ SetWireValue(metric, type, value);
+ payload.Metrics.Add(metric);
+ }
+
+ return Decode(payload);
+ }
+
+ /// A DDEATH — published by the edge node, and therefore sequenced.
+ private static SparkplugPayload Ddeath(ulong seq) =>
+ Decode(new Payload { Seq = seq, Timestamp = 1721822405000UL });
+
+ /// An NDEATH — the broker-published Last Will: a bdSeq and NO seq at all.
+ private static SparkplugPayload Ndeath(ulong bdSeq)
+ {
+ var payload = new Payload { Timestamp = 1721822405000UL };
+ payload.Metrics.Add(new Payload.Types.Metric
+ {
+ Name = "bdSeq",
+ Datatype = (uint)TahuDataType.Uint64,
+ LongValue = bdSeq,
+ });
+ return Decode(payload);
+ }
+
+ private static Payload.Types.Metric BirthMetric(string name, ulong alias, TahuDataType type, object value)
+ {
+ var metric = new Payload.Types.Metric { Name = name, Alias = alias, Datatype = (uint)type };
+ SetWireValue(metric, type, value);
+ return metric;
+ }
+
+ ///
+ /// Writes a value into the oneof field Sparkplug actually carries it in — including the
+ /// two's-complement-in-an-unsigned-field encoding for the signed integer types, which is the
+ /// whole reason exists.
+ ///
+ private static void SetWireValue(Payload.Types.Metric metric, TahuDataType type, object value)
+ {
+ switch (type)
+ {
+ case TahuDataType.Boolean:
+ metric.BooleanValue = (bool)value;
+ break;
+ case TahuDataType.Float:
+ metric.FloatValue = (float)value;
+ break;
+ case TahuDataType.Double:
+ metric.DoubleValue = (double)value;
+ break;
+ case TahuDataType.Int8:
+ case TahuDataType.Int16:
+ case TahuDataType.Int32:
+ metric.IntValue = unchecked((uint)Convert.ToInt32(value));
+ break;
+ case TahuDataType.Uint8:
+ case TahuDataType.Uint16:
+ case TahuDataType.Uint32:
+ metric.IntValue = Convert.ToUInt32(value);
+ break;
+ case TahuDataType.Int64:
+ metric.LongValue = unchecked((ulong)Convert.ToInt64(value));
+ break;
+ case TahuDataType.Uint64:
+ case TahuDataType.DateTime:
+ metric.LongValue = Convert.ToUInt64(value);
+ break;
+ case TahuDataType.String:
+ case TahuDataType.Text:
+ case TahuDataType.Uuid:
+ metric.StringValue = (string)value;
+ break;
+ default:
+ throw new NotSupportedException($"Test helper does not encode {type}.");
+ }
+ }
+
+ /// Records every NCMD so the rebirth policy is assertable without a broker.
+ private sealed class RecordingPublishTransport : IMqttPublishTransport
+ {
+ public List Topics { get; } = [];
+
+ public List Payloads { get; } = [];
+
+ public Task PublishAsync(string topic, byte[] payload, int qos, bool retain, CancellationToken cancellationToken)
+ {
+ lock (Topics)
+ {
+ Topics.Add(topic);
+ Payloads.Add(payload);
+ }
+
+ // A retained NCMD replays to the edge node on every future subscribe, driving a permanent
+ // rebirth loop from one stale command. Assert it here so every rebirth path is covered.
+ retain.ShouldBeFalse();
+ return Task.CompletedTask;
+ }
+ }
+
+ /// Records every SUBSCRIBE so the group/STATE filters are assertable without a broker.
+ private sealed class FakeSubscribeTransport : IMqttSubscribeTransport
+ {
+ public List Filters { get; } = [];
+
+ /// Topics for which the fake SUBACK reports a rejection.
+ public Func Reject { get; set; } = _ => false;
+
+ public Task> SubscribeAsync(
+ IReadOnlyList filters,
+ CancellationToken cancellationToken)
+ {
+ Filters.AddRange(filters);
+ IReadOnlyList outcomes =
+ [
+ .. filters.Select(f => new MqttSubscribeOutcome(
+ f.Topic,
+ !Reject(f.Topic),
+ Reject(f.Topic) ? "NotAuthorized" : "GrantedQoS1")),
+ ];
+ return Task.FromResult(outcomes);
+ }
+ }
+}