diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SparkplugCodec.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SparkplugCodec.cs
new file mode 100644
index 00000000..5234cbbe
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SparkplugCodec.cs
@@ -0,0 +1,311 @@
+using Google.Protobuf;
+using Org.Eclipse.Tahu.Protobuf;
+using TahuDataType = Org.Eclipse.Tahu.Protobuf.DataType;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
+
+///
+/// Decodes Sparkplug-B wire bytes into — the driver-side projection
+/// the ingest state machine consumes. Decode only; the NCMD encode path lives in
+/// RebirthRequester.
+///
+///
+///
+/// Nothing here throws, for any input. This runs on MQTTnet's shared dispatcher thread,
+/// behind an unauthenticated firehose the plant's edge nodes publish into. An escaping
+/// exception would not degrade one tag — it would stall or kill delivery for every
+/// subscription on the connection. Garbage bytes, a truncated body, a zero-length payload, a
+/// valid protobuf of some other schema and a pathologically nested Template all resolve to a
+/// verdict: returns and
+/// returns .
+///
+///
+/// Zero-length input is invalid, not empty. Protobuf would happily parse zero bytes as
+/// "a Payload with every field defaulted", but in Sparkplug an empty MQTT body is never a
+/// legitimate message — treating it as a well-formed payload carrying no metrics is how a
+/// truncated-to-nothing body gets mistaken for a real one. It is reported as invalid.
+///
+///
+/// Explicit presence is preserved, everywhere. The vendored schema is proto2 precisely
+/// so absence and zero stay distinguishable, and every projection below reads
+/// Has{Seq,Name,Alias,Datatype,Timestamp,IsNull} rather than testing a value against its
+/// default. Two cases make this load-bearing rather than pedantic: a Sparkplug NBIRTH is
+/// REQUIRED to carry seq = 0, and every DATA metric after a birth carries an alias with
+/// no name and no datatype. A zero-check decoder reports "no sequence" for every
+/// birth and "" for every DATA metric name.
+///
+///
+/// Values are projected raw — nothing is coerced or reinterpreted here. A metric's value
+/// arrives as the CLR type of the wire field that carried it (see
+/// ); coercion against the authored tag's declared
+/// DriverDataType is the consumer's job, and follows this driver's standing rule that a
+/// value which does not fit is refused rather than silently converted. The one wire-level
+/// translation this type does offer is , because Sparkplug's
+/// two's-complement-in-an-unsigned-field encoding of signed integers is a property of the
+/// wire, not of the tag.
+///
+///
+/// DataSet and Template metrics are out of scope for v1. They decode to
+/// with a null value — deliberately visible, so a
+/// consumer can warn about a metric it cannot serve instead of silently treating it as one
+/// that was never published.
+///
+///
+public static class SparkplugCodec
+{
+ /// Decodes Sparkplug-B wire bytes, reporting success rather than throwing.
+ /// The MQTT message body.
+ ///
+ /// The decoded payload on success; otherwise. Never
+ /// .
+ ///
+ ///
+ /// when the bytes parsed as a Sparkplug-B Payload. Protobuf is a
+ /// permissive, self-describing-only-by-convention format, so this means "parsed" rather than
+ /// "was genuinely produced by a Sparkplug edge node" — unknown fields are preserved by
+ /// Google.Protobuf and simply ignored here.
+ ///
+ public static bool TryDecode(ReadOnlySpan wire, out SparkplugPayload payload)
+ {
+ payload = SparkplugPayload.Invalid;
+
+ if (wire.IsEmpty)
+ {
+ return false;
+ }
+
+ Payload proto;
+ try
+ {
+ proto = Payload.Parser.ParseFrom(wire);
+ }
+ catch (Exception)
+ {
+ // Deliberately broad. InvalidProtocolBufferException covers malformed, truncated and
+ // over-nested input, but this is the dispatcher-thread boundary: the contract is a
+ // verdict for EVERY input, and narrowing the catch would trade that guarantee for a
+ // taxonomy nobody downstream can act on.
+ return false;
+ }
+
+ var metrics = proto.Metrics.Count == 0
+ ? []
+ : new SparkplugMetric[proto.Metrics.Count];
+
+ for (var i = 0; i < proto.Metrics.Count; i++)
+ {
+ metrics[i] = ProjectMetric(proto.Metrics[i]);
+ }
+
+ payload = new SparkplugPayload(
+ IsValid: true,
+ Seq: proto.HasSeq ? proto.Seq : null,
+ TimestampMs: proto.HasTimestamp ? proto.Timestamp : null,
+ Metrics: metrics);
+
+ return true;
+ }
+
+ ///
+ /// Decodes Sparkplug-B wire bytes, returning when they
+ /// cannot be decoded.
+ ///
+ /// The MQTT message body.
+ /// The decoded payload — never .
+ ///
+ /// The convenience shape. Check : an undecodable
+ /// body and a genuinely metric-less one both present as an empty
+ /// , and only the flag tells them apart. Prefer
+ /// where the verdict drives control flow.
+ ///
+ public static SparkplugPayload Decode(ReadOnlySpan wire) =>
+ TryDecode(wire, out var payload) ? payload : SparkplugPayload.Invalid;
+
+ ///
+ /// Reinterprets a raw metric value as the signed integer Sparkplug encoded it as, when its
+ /// datatype is a signed integer type; returns the value unchanged for every other datatype.
+ ///
+ ///
+ /// A — a for the 8/16/32-bit arms, a
+ /// for the 64-bit arm.
+ ///
+ /// The metric's Sparkplug datatype, from its birth or its own field.
+ ///
+ /// / / / for the
+ /// signed integer datatypes; untouched otherwise (including when it is
+ /// or not the wire type the datatype implies).
+ ///
+ ///
+ /// Sparkplug carries Int8/Int16/Int32 in the unsigned
+ /// int_value field and Int64 in the unsigned long_value field, as two's
+ /// complement. Handing the raw field to a consumer publishes 4294967254 for a tag whose
+ /// value is -42 — a wrong value that looks entirely plausible, arrives with Good
+ /// quality, and is invisible until someone reads a gauge. This is the single call that undoes
+ /// it, and it is separate from the decode itself because a DATA metric carries no datatype of
+ /// its own: only the consumer, holding the alias table built from the birth, knows which
+ /// datatype applies.
+ ///
+ public static object? ReinterpretSigned(object? value, TahuDataType datatype) => datatype switch
+ {
+ TahuDataType.Int8 when value is uint raw => unchecked((sbyte)raw),
+ TahuDataType.Int16 when value is uint raw => unchecked((short)raw),
+ TahuDataType.Int32 when value is uint raw => unchecked((int)raw),
+ TahuDataType.Int64 when value is ulong raw => unchecked((long)raw),
+ _ => value,
+ };
+
+ /// Projects one generated metric onto the driver-side shape, presence intact.
+ /// The generated metric.
+ /// The projection.
+ private static SparkplugMetric ProjectMetric(Payload.Types.Metric metric)
+ {
+ var (kind, value) = ProjectValue(metric);
+
+ return new SparkplugMetric(
+ Name: metric.HasName ? metric.Name : null,
+ Alias: metric.HasAlias ? metric.Alias : null,
+
+ // Cast, never filter: an index this build's enum does not define is a future Sparkplug
+ // revision or a misbehaving publisher, and dropping it would deny the mapping layer the
+ // only evidence it has.
+ DataType: metric.HasDatatype ? (TahuDataType)metric.Datatype : null,
+ TimestampMs: metric.HasTimestamp ? metric.Timestamp : null,
+ ValueKind: kind,
+ Value: value);
+ }
+
+ /// Resolves a metric's value oneof to a kind plus a raw CLR value.
+ /// The generated metric.
+ /// The value kind and the projected value.
+ ///
+ /// is_null wins over the oneof: the Sparkplug spec's whole reason for the field is
+ /// that some datatypes have no spare sentinel, so a publisher that sets it means "null" even if
+ /// a value field is also populated.
+ ///
+ private static (SparkplugValueKind Kind, object? Value) ProjectValue(Payload.Types.Metric metric)
+ {
+ if (metric.HasIsNull && metric.IsNull)
+ {
+ return (SparkplugValueKind.Null, null);
+ }
+
+ return metric.ValueCase switch
+ {
+ Payload.Types.Metric.ValueOneofCase.IntValue => (SparkplugValueKind.Scalar, metric.IntValue),
+ Payload.Types.Metric.ValueOneofCase.LongValue => (SparkplugValueKind.Scalar, metric.LongValue),
+ Payload.Types.Metric.ValueOneofCase.FloatValue => (SparkplugValueKind.Scalar, metric.FloatValue),
+ Payload.Types.Metric.ValueOneofCase.DoubleValue => (SparkplugValueKind.Scalar, metric.DoubleValue),
+ Payload.Types.Metric.ValueOneofCase.BooleanValue => (SparkplugValueKind.Scalar, metric.BooleanValue),
+ Payload.Types.Metric.ValueOneofCase.StringValue => (SparkplugValueKind.Scalar, metric.StringValue),
+
+ // Copied out of the ByteString: the projection must outlive the parsed message.
+ Payload.Types.Metric.ValueOneofCase.BytesValue =>
+ (SparkplugValueKind.Scalar, metric.BytesValue.ToByteArray()),
+
+ // v1 scope boundary — decoded as a visible refusal, not as an exception or a silent drop.
+ Payload.Types.Metric.ValueOneofCase.DatasetValue => (SparkplugValueKind.Unsupported, null),
+ Payload.Types.Metric.ValueOneofCase.TemplateValue => (SparkplugValueKind.Unsupported, null),
+ Payload.Types.Metric.ValueOneofCase.ExtensionValue => (SparkplugValueKind.Unsupported, null),
+
+ _ => (SparkplugValueKind.Absent, null),
+ };
+ }
+}
+
+///
+/// A decoded Sparkplug-B payload: the driver-side projection of the generated Payload,
+/// carrying only what the ingest state machine consumes.
+///
+///
+/// for a payload that could not be decoded. An invalid payload also has an
+/// empty list, so this flag is the only thing distinguishing an
+/// undecodable body from a legitimately metric-less one.
+///
+///
+/// The payload sequence number, or when the message carried none. Kept as
+/// the wire's rather than narrowed to a byte: the Sparkplug range is 0–255, but
+/// a publisher that violates it is reporting a fact the consumer should be able to see and reject,
+/// not one this layer should silently truncate into a plausible-looking sequence number.
+///
+/// The payload timestamp in Sparkplug epoch milliseconds, or null when absent.
+/// The payload's metrics, in wire order. Never .
+public sealed record SparkplugPayload(
+ bool IsValid,
+ ulong? Seq,
+ ulong? TimestampMs,
+ IReadOnlyList Metrics)
+{
+ /// The shared "could not decode this" result.
+ public static readonly SparkplugPayload Invalid = new(false, null, null, []);
+}
+
+/// One decoded Sparkplug metric.
+///
+/// The metric's stable name, or when the message omitted it — which every
+/// real DATA metric after a birth does, carrying only . Distinguishing
+/// this from an empty string is the reason the vendored schema is proto2.
+///
+/// The per-birth alias, or when the metric carried none.
+///
+/// The metric's declared datatype, or when absent (again, the normal case
+/// for a DATA metric — its datatype comes from the birth). An index this build's enum does not
+/// define is preserved as an undefined enum value rather than dropped.
+///
+///
+/// The metric's own acquisition timestamp in Sparkplug epoch milliseconds, or
+/// when it carried none — in which case the payload's timestamp applies.
+///
+///
+/// What means. Check this before reading the value: a null value is
+/// ambiguous between "explicitly null", "absent" and "a kind v1 does not support".
+///
+///
+/// The raw wire value, boxed: (int_value),
+/// (long_value), , , ,
+/// or [] — and for every
+/// other than .
+///
+/// Signed integers arrive as their unsigned two's-complement wire value — a
+/// DataType.Int32 metric holding -42 is a of 4294967254 here. Run it
+/// through with the metric's datatype (from the
+/// birth, for a DATA metric) before publishing it.
+///
+///
+/// Boxing is deliberate: the consumer coerces against the authored tag's declared
+/// DriverDataType and hands the result to a DataValueSnapshot, which boxes
+/// anyway, so a discriminated-union shape would buy an unboxing hop and cost every consumer a
+/// switch over a dozen arms.
+///
+///
+public readonly record struct SparkplugMetric(
+ string? Name,
+ ulong? Alias,
+ TahuDataType? DataType,
+ ulong? TimestampMs,
+ SparkplugValueKind ValueKind,
+ object? Value);
+
+/// What a decoded metric's represents.
+///
+/// Three of the four members have a null and mean entirely
+/// different things, which is exactly why the distinction is carried explicitly rather than left
+/// for a consumer to infer from a null.
+///
+public enum SparkplugValueKind
+{
+ /// The metric's value oneof carried nothing at all.
+ Absent = 0,
+
+ /// The metric set is_null: it exists, and its value is explicitly null.
+ Null,
+
+ /// holds the raw wire value.
+ Scalar,
+
+ ///
+ /// A DataSet, Template or extension value — decoded and reported, but not
+ /// supported in v1. The consumer should warn and skip the metric rather than treat it as
+ /// missing.
+ ///
+ Unsupported,
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/Golden/nbirth.bin b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/Golden/nbirth.bin
new file mode 100644
index 00000000..19c14ffe
Binary files /dev/null and b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/Golden/nbirth.bin differ
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/Golden/ndata.bin b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/Golden/ndata.bin
new file mode 100644
index 00000000..d87d41d8
Binary files /dev/null and b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/Golden/ndata.bin differ
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugCodecTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugCodecTests.cs
new file mode 100644
index 00000000..77cbff89
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugCodecTests.cs
@@ -0,0 +1,392 @@
+using Google.Protobuf;
+using Org.Eclipse.Tahu.Protobuf;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
+
+///
+/// Covers : the golden NBIRTH/NDATA vectors decode into the driver-side
+/// projection with explicit presence preserved, and no input — malformed, truncated, empty, or
+/// simply not Sparkplug — makes the decoder throw.
+///
+public sealed class SparkplugCodecTests
+{
+ ///
+ /// The headline case from the plan: a golden NBIRTH yields its sequence number and its metric
+ /// catalog, with name, alias, datatype and value all surviving.
+ ///
+ [Fact]
+ public void Decode_GoldenNbirth_ExtractsMetrics()
+ {
+ var payload = SparkplugCodec.Decode(SparkplugGoldenPayloads.Read(SparkplugGoldenPayloads.NbirthFile));
+
+ payload.IsValid.ShouldBeTrue();
+ payload.Seq.ShouldBe(0UL);
+ payload.Metrics.ShouldContain(m =>
+ m.Name == "Temperature"
+ && m.Alias == 5UL
+ && m.DataType == DataType.Float
+ && Equals(m.Value, 21.5f));
+ }
+
+ ///
+ /// Explicit presence, case 1. A Sparkplug NBIRTH is REQUIRED to carry seq = 0, so a
+ /// decoder that reads absence off a zero value reports "no sequence" for every birth ever
+ /// published — and the sequence tracker then has no birth to anchor on.
+ ///
+ [Fact]
+ public void Decode_GoldenNbirth_SeqZero_IsPresentNotAbsent()
+ {
+ var payload = SparkplugCodec.Decode(SparkplugGoldenPayloads.Read(SparkplugGoldenPayloads.NbirthFile));
+
+ payload.Seq.HasValue.ShouldBeTrue();
+ payload.Seq!.Value.ShouldBe(0UL);
+ }
+
+ ///
+ /// Explicit presence, case 2. A payload that genuinely omits seq must report
+ /// absence, not zero — the mirror image of the test above, and the pair is what makes the
+ /// distinction falsifiable rather than incidental.
+ ///
+ [Fact]
+ public void Decode_PayloadWithoutSeq_ReportsAbsentSeq()
+ {
+ var wire = new Payload { Timestamp = 1UL }.ToByteArray();
+
+ SparkplugCodec.Decode(wire).Seq.ShouldBeNull();
+ }
+
+ ///
+ /// Explicit presence, case 3. Every real Sparkplug DATA metric omits name and
+ /// datatype and carries only its alias. Reporting an absent name as the empty string
+ /// would make it indistinguishable from a (malformed) metric genuinely named "".
+ ///
+ [Fact]
+ public void Decode_GoldenNdata_MetricsCarryAliasOnly_NameAndDataTypeAbsent()
+ {
+ var payload = SparkplugCodec.Decode(SparkplugGoldenPayloads.Read(SparkplugGoldenPayloads.NdataFile));
+
+ payload.IsValid.ShouldBeTrue();
+ payload.Seq.ShouldBe(1UL);
+ payload.Metrics.Count.ShouldBe(3);
+
+ foreach (var metric in payload.Metrics)
+ {
+ metric.Name.ShouldBeNull();
+ metric.DataType.ShouldBeNull();
+ metric.Alias.HasValue.ShouldBeTrue();
+ }
+
+ payload.Metrics.ShouldContain(m => m.Alias == SparkplugGoldenPayloads.TemperatureAlias
+ && Equals(m.Value, 22.25f));
+ }
+
+ ///
+ /// Explicit presence, case 4. "The metric's value is null" (is_null) is a different
+ /// fact from "the metric carried no value field", and both differ from "the value is zero".
+ ///
+ [Fact]
+ public void Decode_GoldenNbirth_ExplicitNullMetric_IsNullKind()
+ {
+ var payload = SparkplugCodec.Decode(SparkplugGoldenPayloads.Read(SparkplugGoldenPayloads.NbirthFile));
+
+ var missing = payload.Metrics.Single(m => m.Name == "Missing");
+ missing.ValueKind.ShouldBe(SparkplugValueKind.Null);
+ missing.Value.ShouldBeNull();
+ missing.DataType.ShouldBe(DataType.Float);
+ }
+
+ ///
+ /// Explicit presence, case 5 — the one that makes the other four falsifiable. A publisher
+ /// may legitimately set a metric's name to the empty string, its datatype to Unknown (0)
+ /// and its timestamp to 0 (the Unix epoch). Those are present-and-default, and the golden
+ /// vectors alone cannot tell a presence check from a default check because nothing in them sets
+ /// a field to its own default. This pins the distinction directly: set-to-default must decode as
+ /// PRESENT, unset must decode as ABSENT, on every field that carries presence.
+ ///
+ [Fact]
+ public void Decode_FieldsSetToTheirOwnDefault_ArePresent_WhileUnsetFieldsAreAbsent()
+ {
+ var payload = new Payload();
+ payload.Metrics.Add(new Payload.Types.Metric
+ {
+ Name = string.Empty,
+ Alias = 0UL,
+ Datatype = (uint)DataType.Unknown,
+ Timestamp = 0UL,
+ IntValue = 0U,
+ });
+ payload.Metrics.Add(new Payload.Types.Metric { StringValue = "unset-everything-else" });
+
+ var decoded = SparkplugCodec.Decode(payload.ToByteArray());
+
+ var present = decoded.Metrics[0];
+ present.Name.ShouldBe(string.Empty);
+ present.Alias.ShouldBe(0UL);
+ present.DataType.ShouldBe(DataType.Unknown);
+ present.TimestampMs.ShouldBe(0UL);
+
+ var absent = decoded.Metrics[1];
+ absent.Name.ShouldBeNull();
+ absent.Alias.ShouldBeNull();
+ absent.DataType.ShouldBeNull();
+ absent.TimestampMs.ShouldBeNull();
+ }
+
+ /// A metric carrying neither a value nor is_null decodes as absent, not null.
+ [Fact]
+ public void Decode_MetricWithNoValueAndNoIsNull_IsAbsentKind()
+ {
+ var payload = new Payload { Seq = 4UL };
+ payload.Metrics.Add(new Payload.Types.Metric { Name = "Bare" });
+
+ var decoded = SparkplugCodec.Decode(payload.ToByteArray());
+
+ decoded.Metrics.Single().ValueKind.ShouldBe(SparkplugValueKind.Absent);
+ decoded.Metrics.Single().Value.ShouldBeNull();
+ }
+
+ ///
+ /// Every scalar arm of the metric value oneof projects to a CLR value of the wire's own
+ /// type — nothing is coerced here; coercion against the authored tag type happens downstream.
+ ///
+ [Fact]
+ public void Decode_GoldenNbirth_ProjectsEveryScalarValueArm()
+ {
+ var payload = SparkplugCodec.Decode(SparkplugGoldenPayloads.Read(SparkplugGoldenPayloads.NbirthFile));
+
+ var byName = payload.Metrics.Where(m => m.Name is not null).ToDictionary(m => m.Name!, m => m);
+
+ byName["bdSeq"].Value.ShouldBe(7UL); // long_value → ulong
+ byName["Node Control/Rebirth"].Value.ShouldBe(false); // boolean_value → bool
+ byName["Temperature"].Value.ShouldBe(21.5f); // float_value → float
+ byName["Pressure"].Value.ShouldBe(101.325d); // double_value → double
+ byName["Running"].Value.ShouldBe(true);
+ byName["Serial"].Value.ShouldBe("EDGE-A-001"); // string_value → string
+ byName["Count"].Value.ShouldBe(unchecked((uint)-42)); // int_value → uint, RAW
+ }
+
+ /// A bytes metric projects to a byte array rather than a protobuf ByteString.
+ [Fact]
+ public void Decode_BytesMetric_ProjectsToByteArray()
+ {
+ var payload = new Payload();
+ payload.Metrics.Add(new Payload.Types.Metric
+ {
+ Name = "Blob",
+ Datatype = (uint)DataType.Bytes,
+ BytesValue = ByteString.CopyFrom(1, 2, 3),
+ });
+
+ var decoded = SparkplugCodec.Decode(payload.ToByteArray());
+
+ decoded.Metrics.Single().Value.ShouldBeOfType().ShouldBe(new byte[] { 1, 2, 3 });
+ }
+
+ ///
+ /// DataSet and Template metrics are out of scope for v1. They must decode to an
+ /// explicit "unsupported" kind — never an exception on the MQTT dispatcher thread, and never a
+ /// silently-dropped metric that looks identical to a metric that was never published.
+ ///
+ [Fact]
+ public void Decode_GoldenNbirth_DataSetMetric_IsUnsupported_NotAThrow()
+ {
+ var payload = SparkplugCodec.Decode(SparkplugGoldenPayloads.Read(SparkplugGoldenPayloads.NbirthFile));
+
+ var config = payload.Metrics.Single(m => m.Name == "Config");
+ config.ValueKind.ShouldBe(SparkplugValueKind.Unsupported);
+ config.Value.ShouldBeNull();
+ config.DataType.ShouldBe(DataType.DataSet);
+ }
+
+ /// A Template metric takes the same unsupported path as DataSet.
+ [Fact]
+ public void Decode_TemplateMetric_IsUnsupported()
+ {
+ var payload = new Payload();
+ payload.Metrics.Add(new Payload.Types.Metric
+ {
+ Name = "Motor",
+ Datatype = (uint)DataType.Template,
+ TemplateValue = new Payload.Types.Template { Version = "1", IsDefinition = false },
+ });
+
+ SparkplugCodec.Decode(payload.ToByteArray()).Metrics.Single().ValueKind
+ .ShouldBe(SparkplugValueKind.Unsupported);
+ }
+
+ /// Payload and per-metric timestamps survive as raw Sparkplug epoch milliseconds.
+ [Fact]
+ public void Decode_GoldenVectors_PreserveTimestamps()
+ {
+ var nbirth = SparkplugCodec.Decode(SparkplugGoldenPayloads.Read(SparkplugGoldenPayloads.NbirthFile));
+
+ nbirth.TimestampMs.ShouldBe(SparkplugGoldenPayloads.NbirthTimestampMs);
+ nbirth.Metrics.Single(m => m.Name == "Temperature").TimestampMs
+ .ShouldBe(SparkplugGoldenPayloads.NbirthTimestampMs);
+
+ // A metric that carried no timestamp of its own reports absence, not the payload's.
+ nbirth.Metrics.Single(m => m.Name == "Pressure").TimestampMs.ShouldBeNull();
+ }
+
+ ///
+ /// Sparkplug carries signed integers up to 32 bits as two's complement in the UNSIGNED
+ /// int_value field. The codec hands over the raw wire value; this is the helper that
+ /// turns it back into the number the edge node meant.
+ ///
+ [Theory]
+ [InlineData(DataType.Int8, -5)]
+ [InlineData(DataType.Int16, -1234)]
+ [InlineData(DataType.Int32, -42)]
+ public void ReinterpretSigned_RecoversNegativeIntegersFromTheUnsignedWireField(DataType datatype, int expected)
+ {
+ var raw = unchecked((uint)expected);
+
+ var result = SparkplugCodec.ReinterpretSigned(raw, datatype);
+
+ Convert.ToInt64(result).ShouldBe(expected);
+ }
+
+ /// The 64-bit arm reinterprets long_value; the golden NBIRTH's Count is the 32-bit arm.
+ [Fact]
+ public void ReinterpretSigned_HandlesInt64_AndTheGoldenCountMetric()
+ {
+ SparkplugCodec.ReinterpretSigned(unchecked((ulong)-9_000_000_000L), DataType.Int64).ShouldBe(-9_000_000_000L);
+
+ var payload = SparkplugCodec.Decode(SparkplugGoldenPayloads.Read(SparkplugGoldenPayloads.NbirthFile));
+ var count = payload.Metrics.Single(m => m.Name == "Count");
+ SparkplugCodec.ReinterpretSigned(count.Value, count.DataType!.Value)
+ .ShouldBe(SparkplugGoldenPayloads.CountBirthValue);
+ }
+
+ /// Unsigned and non-integer datatypes pass through untouched.
+ [Fact]
+ public void ReinterpretSigned_LeavesUnsignedAndNonIntegerValuesAlone()
+ {
+ SparkplugCodec.ReinterpretSigned(7U, DataType.Uint32).ShouldBe(7U);
+ SparkplugCodec.ReinterpretSigned(21.5f, DataType.Float).ShouldBe(21.5f);
+ SparkplugCodec.ReinterpretSigned("text", DataType.String).ShouldBe("text");
+ SparkplugCodec.ReinterpretSigned(null, DataType.Int32).ShouldBeNull();
+ }
+
+ ///
+ /// A datatype index this build's enum does not define (a future Sparkplug revision, or a
+ /// misbehaving publisher) is preserved verbatim rather than dropped or coerced to Unknown —
+ /// the mapping layer decides what to do with it, and it cannot decide about data it never saw.
+ ///
+ [Fact]
+ public void Decode_UnrecognisedDataTypeIndex_IsPreservedVerbatim()
+ {
+ var payload = new Payload();
+ payload.Metrics.Add(new Payload.Types.Metric { Name = "Future", Datatype = 250U, IntValue = 1U });
+
+ var decoded = SparkplugCodec.Decode(payload.ToByteArray());
+
+ ((int)decoded.Metrics.Single().DataType!.Value).ShouldBe(250);
+ }
+
+ ///
+ /// Never throws, case 1. Zero-length input is not "a payload with no metrics" — it is no
+ /// answer at all, and is reported as invalid so a truncated-to-nothing body cannot pass as a
+ /// well-formed message with an empty metric list.
+ ///
+ [Fact]
+ public void TryDecode_EmptyInput_IsInvalid_AndDoesNotThrow()
+ {
+ SparkplugCodec.TryDecode([], out var payload).ShouldBeFalse();
+ payload.IsValid.ShouldBeFalse();
+ payload.Metrics.ShouldBeEmpty();
+ payload.Seq.ShouldBeNull();
+ }
+
+ ///
+ /// Never throws, case 2. Garbage bytes: a decoder that let an exception escape here would
+ /// take down the MQTT dispatcher thread — every subscription on the connection, not one tag.
+ ///
+ [Fact]
+ public void TryDecode_GarbageBytes_NeverThrows()
+ {
+ var random = new Random(20260724);
+
+ for (var i = 0; i < 2000; i++)
+ {
+ var bytes = new byte[random.Next(1, 64)];
+ random.NextBytes(bytes);
+
+ // The verdict is irrelevant — some random byte strings ARE valid protobuf. Not throwing is
+ // the whole assertion.
+ Should.NotThrow(() => SparkplugCodec.TryDecode(bytes, out _));
+ }
+ }
+
+ ///
+ /// Never throws, case 3. Every truncation of a real payload — the shape a dropped
+ /// connection or a size-capped broker actually produces.
+ ///
+ [Fact]
+ public void TryDecode_EveryTruncationOfAGoldenVector_NeverThrows()
+ {
+ foreach (var file in new[] { SparkplugGoldenPayloads.NbirthFile, SparkplugGoldenPayloads.NdataFile })
+ {
+ var full = SparkplugGoldenPayloads.Read(file);
+ for (var length = 0; length <= full.Length; length++)
+ {
+ var prefix = full.AsSpan(0, length).ToArray();
+ Should.NotThrow(() => SparkplugCodec.TryDecode(prefix, out _));
+ }
+ }
+ }
+
+ ///
+ /// Never throws, case 4. A valid protobuf message that is not a Sparkplug payload. Protobuf
+ /// is permissive, so this may well parse (as unknown fields) — what matters is that it neither
+ /// throws nor invents metrics.
+ ///
+ [Fact]
+ public void TryDecode_ValidProtobufThatIsNotASparkplugPayload_NeverThrows()
+ {
+ // A DataSet message, serialized standalone — legal protobuf, wrong schema.
+ var alien = new Payload.Types.DataSet { NumOfColumns = 3UL };
+ alien.Columns.Add("a");
+
+ Should.NotThrow(() => SparkplugCodec.TryDecode(alien.ToByteArray(), out _));
+ }
+
+ ///
+ /// Never throws, case 5. A deeply nested Template — protobuf's recursion limit rejects it,
+ /// and the rejection must arrive as a verdict rather than as an exception.
+ ///
+ [Fact]
+ public void TryDecode_PathologicallyNestedTemplate_NeverThrows()
+ {
+ var template = new Payload.Types.Template { Version = "1" };
+ for (var depth = 0; depth < 200; depth++)
+ {
+ var outer = new Payload.Types.Template { Version = "1" };
+ outer.Metrics.Add(new Payload.Types.Metric { Name = "n", TemplateValue = template });
+ template = outer;
+ }
+
+ var payload = new Payload();
+ payload.Metrics.Add(new Payload.Types.Metric { Name = "Deep", TemplateValue = template });
+
+ Should.NotThrow(() => SparkplugCodec.TryDecode(payload.ToByteArray(), out _));
+ }
+
+ ///
+ /// is the convenience shape the plan's call sites use: it
+ /// never returns and never throws, reporting failure through
+ /// .
+ ///
+ [Fact]
+ public void Decode_OnUndecodableInput_ReturnsTheInvalidPayload_RatherThanThrowing()
+ {
+ var payload = SparkplugCodec.Decode([]);
+
+ payload.ShouldNotBeNull();
+ payload.IsValid.ShouldBeFalse();
+ payload.Metrics.ShouldBeEmpty();
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugGoldenPayloads.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugGoldenPayloads.cs
new file mode 100644
index 00000000..29b0544f
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugGoldenPayloads.cs
@@ -0,0 +1,216 @@
+using Google.Protobuf;
+using Org.Eclipse.Tahu.Protobuf;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
+
+///
+/// Builds the two Sparkplug-B wire vectors committed under Golden/ — an NBIRTH and the
+/// NDATA that follows it — and names the files they live in.
+///
+///
+///
+/// Why a generator instead of bytes captured off a real edge node. A capture would pin
+/// one vendor's encoder rather than the schema, could not be regenerated when the vendored
+/// proto is re-vendored, and would drag an unaudited third-party payload into the repo. These
+/// payloads are hand-built from the generated types, serialized once, and committed; the
+/// SparkplugGoldenVectorTests drift guard re-runs this builder on every test run and
+/// fails if the committed bytes no longer match, which is what makes the files an actual
+/// regression pin rather than decoration.
+///
+///
+/// Every field in here is load-bearing. The NBIRTH carries seq = 0 (present and
+/// zero — the case a Seq != 0 presence check gets wrong), a negative Int32 encoded the
+/// Sparkplug way (two's complement in an unsigned int_value), an explicit
+/// is_null metric, and a DataSet metric that v1 does not support. The NDATA
+/// carries metrics with no name and no datatype — alias only — which is how every real
+/// Sparkplug DATA message after a birth looks, and the case an "absent means empty string"
+/// decoder gets wrong. Do not "tidy" these payloads.
+///
+///
+internal static class SparkplugGoldenPayloads
+{
+ /// Repo-relative directory the committed vectors live in, inside the test project.
+ public const string Directory = "Golden";
+
+ /// File name of the NBIRTH vector.
+ public const string NbirthFile = "nbirth.bin";
+
+ /// File name of the NDATA vector.
+ public const string NdataFile = "ndata.bin";
+
+ /// NBIRTH payload timestamp — 2024-07-24T12:00:00Z, in Sparkplug's epoch milliseconds.
+ public const ulong NbirthTimestampMs = 1721822400000UL;
+
+ /// NDATA payload timestamp — five seconds after the birth.
+ public const ulong NdataTimestampMs = 1721822405000UL;
+
+ /// The alias the NBIRTH binds to Temperature, reused by the NDATA.
+ public const ulong TemperatureAlias = 5UL;
+
+ /// The alias the NBIRTH binds to Count — the negative-Int32 case.
+ public const ulong CountAlias = 9UL;
+
+ /// The alias the NBIRTH binds to Missing — the explicit-null case.
+ public const ulong MissingAlias = 11UL;
+
+ /// The Count metric's value in the NBIRTH — negative, to pin the wire encoding.
+ public const int CountBirthValue = -42;
+
+ /// The Count metric's value in the NDATA.
+ public const int CountDataValue = -43;
+
+ ///
+ /// Builds the NBIRTH: seq = 0, a full metric catalog with names, aliases and datatypes.
+ ///
+ /// The payload; serialize with .
+ public static Payload BuildNbirth()
+ {
+ var payload = new Payload
+ {
+ Timestamp = NbirthTimestampMs,
+
+ // Present AND zero. Sparkplug REQUIRES an NBIRTH to carry seq = 0, so any decoder that
+ // infers absence from a zero value reports "no sequence" for every birth it ever sees.
+ Seq = 0UL,
+ };
+
+ payload.Metrics.Add(new Payload.Types.Metric
+ {
+ Name = "bdSeq",
+ Datatype = (uint)DataType.Uint64,
+ LongValue = 7UL,
+ });
+
+ payload.Metrics.Add(new Payload.Types.Metric
+ {
+ Name = "Node Control/Rebirth",
+ Datatype = (uint)DataType.Boolean,
+ BooleanValue = false,
+ });
+
+ payload.Metrics.Add(new Payload.Types.Metric
+ {
+ Name = "Temperature",
+ Alias = TemperatureAlias,
+ Timestamp = NbirthTimestampMs,
+ Datatype = (uint)DataType.Float,
+ FloatValue = 21.5f,
+ });
+
+ payload.Metrics.Add(new Payload.Types.Metric
+ {
+ Name = "Pressure",
+ Alias = 6UL,
+ Datatype = (uint)DataType.Double,
+ DoubleValue = 101.325d,
+ });
+
+ payload.Metrics.Add(new Payload.Types.Metric
+ {
+ Name = "Running",
+ Alias = 7UL,
+ Datatype = (uint)DataType.Boolean,
+ BooleanValue = true,
+ });
+
+ payload.Metrics.Add(new Payload.Types.Metric
+ {
+ Name = "Serial",
+ Alias = 8UL,
+ Datatype = (uint)DataType.String,
+ StringValue = "EDGE-A-001",
+ });
+
+ // Sparkplug carries every signed integer up to 32 bits in the UNSIGNED int_value field as
+ // two's complement, so -42 goes on the wire as 4294967254. A decoder that hands the raw
+ // uint32 straight to a consumer publishes 4294967254 for a tag whose value is -42.
+ payload.Metrics.Add(new Payload.Types.Metric
+ {
+ Name = "Count",
+ Alias = CountAlias,
+ Datatype = (uint)DataType.Int32,
+ IntValue = unchecked((uint)CountBirthValue),
+ });
+
+ // DataSet is explicitly out of scope for v1 — present here so "unsupported" is a decoded,
+ // testable outcome rather than an exception on the MQTT dispatcher thread.
+ var dataSet = new Payload.Types.DataSet { NumOfColumns = 1UL };
+ dataSet.Columns.Add("col0");
+ dataSet.Types_.Add((uint)DataType.Int32);
+ var row = new Payload.Types.DataSet.Types.Row();
+ row.Elements.Add(new Payload.Types.DataSet.Types.DataSetValue { IntValue = 1U });
+ dataSet.Rows.Add(row);
+
+ payload.Metrics.Add(new Payload.Types.Metric
+ {
+ Name = "Config",
+ Alias = 10UL,
+ Datatype = (uint)DataType.DataSet,
+ DatasetValue = dataSet,
+ });
+
+ // is_null = true with NO value in the oneof: "this metric exists and its value is null",
+ // which is a different fact from "this metric carried no value field".
+ payload.Metrics.Add(new Payload.Types.Metric
+ {
+ Name = "Missing",
+ Alias = MissingAlias,
+ Datatype = (uint)DataType.Float,
+ IsNull = true,
+ });
+
+ return payload;
+ }
+
+ ///
+ /// Builds the NDATA that follows : seq = 1 and metrics carrying
+ /// alias only — no name, no datatype — exactly as a real edge node publishes them.
+ ///
+ /// The payload; serialize with .
+ public static Payload BuildNdata()
+ {
+ var payload = new Payload
+ {
+ Timestamp = NdataTimestampMs,
+ Seq = 1UL,
+ };
+
+ payload.Metrics.Add(new Payload.Types.Metric
+ {
+ Alias = TemperatureAlias,
+ Timestamp = NdataTimestampMs,
+ FloatValue = 22.25f,
+ });
+
+ payload.Metrics.Add(new Payload.Types.Metric
+ {
+ Alias = CountAlias,
+ IntValue = unchecked((uint)CountDataValue),
+ });
+
+ payload.Metrics.Add(new Payload.Types.Metric
+ {
+ Alias = MissingAlias,
+ IsNull = true,
+ });
+
+ return payload;
+ }
+
+ /// Reads a committed vector from the test binary's output directory.
+ /// or .
+ /// The committed wire bytes.
+ public static byte[] Read(string fileName) => File.ReadAllBytes(PathTo(fileName));
+
+ /// Resolves a vector's path under the test binary's output directory.
+ /// or .
+ /// The absolute path.
+ ///
+ /// Rooted at rather than the process working directory:
+ /// the runner's cwd is not guaranteed to be the output folder, and a relative read that
+ /// happens to work under one runner is exactly how a fixture stops being copied without
+ /// anyone noticing.
+ ///
+ public static string PathTo(string fileName) =>
+ Path.Combine(AppContext.BaseDirectory, Directory, fileName);
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugGoldenVectorTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugGoldenVectorTests.cs
new file mode 100644
index 00000000..c8239055
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugGoldenVectorTests.cs
@@ -0,0 +1,87 @@
+using System.Runtime.CompilerServices;
+using Google.Protobuf;
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
+
+///
+/// Guards the committed Golden/*.bin Sparkplug-B wire vectors: they must still be exactly
+/// what produces, and they must actually reach the test
+/// binary's output directory.
+///
+///
+///
+/// Without the drift guard the vectors would be write-once decoration — a re-vendored proto,
+/// a protoc upgrade, or an edited builder could change the encoding while every decode test
+/// kept passing against stale bytes. With it, the committed files are a genuine pin on the
+/// wire format the driver is expected to read.
+///
+///
+/// To regenerate (only after a deliberate, reviewed change to the vectors):
+/// OTOPCUA_SPARKPLUG_GOLDEN_REGEN=1 dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests
+/// --filter "FullyQualifiedName~SparkplugGoldenVectorTests", then review and commit the
+/// resulting .bin diff.
+///
+///
+public sealed class SparkplugGoldenVectorTests
+{
+ private const string RegenEnvVar = "OTOPCUA_SPARKPLUG_GOLDEN_REGEN";
+
+ /// The committed NBIRTH vector is byte-identical to its builder's output.
+ [Fact]
+ public void Nbirth_CommittedBytes_MatchTheBuilder() =>
+ AssertMatchesBuilder(SparkplugGoldenPayloads.NbirthFile, SparkplugGoldenPayloads.BuildNbirth().ToByteArray());
+
+ /// The committed NDATA vector is byte-identical to its builder's output.
+ [Fact]
+ public void Ndata_CommittedBytes_MatchTheBuilder() =>
+ AssertMatchesBuilder(SparkplugGoldenPayloads.NdataFile, SparkplugGoldenPayloads.BuildNdata().ToByteArray());
+
+ ///
+ /// Both vectors are present in the test binary's output directory — i.e. the csproj really does
+ /// copy Golden/**. A missing copy item is invisible in source and turns every decode test
+ /// into a at runtime.
+ ///
+ [Fact]
+ public void GoldenVectors_AreCopiedToTheOutputDirectory()
+ {
+ File.Exists(SparkplugGoldenPayloads.PathTo(SparkplugGoldenPayloads.NbirthFile)).ShouldBeTrue();
+ File.Exists(SparkplugGoldenPayloads.PathTo(SparkplugGoldenPayloads.NdataFile)).ShouldBeTrue();
+ }
+
+ /// Compares a committed vector to freshly built bytes, regenerating first when asked to.
+ /// The vector's file name.
+ /// The bytes the builder produces now.
+ private static void AssertMatchesBuilder(string fileName, byte[] built)
+ {
+ if (Environment.GetEnvironmentVariable(RegenEnvVar) == "1")
+ {
+ // Write to BOTH the source tree (so the change can be committed) and the output directory
+ // (so the assertion below reads what was just written rather than a stale copied file).
+ var source = Path.Combine(SourceDirectory(), SparkplugGoldenPayloads.Directory, fileName);
+ System.IO.Directory.CreateDirectory(Path.GetDirectoryName(source)!);
+ File.WriteAllBytes(source, built);
+
+ var output = SparkplugGoldenPayloads.PathTo(fileName);
+ System.IO.Directory.CreateDirectory(Path.GetDirectoryName(output)!);
+ File.WriteAllBytes(output, built);
+ }
+
+ var path = SparkplugGoldenPayloads.PathTo(fileName);
+ File.Exists(path).ShouldBeTrue(
+ $"Golden vector '{fileName}' is missing from the output directory. If the csproj copy item is "
+ + $"intact, regenerate with {RegenEnvVar}=1.");
+
+ File.ReadAllBytes(path).ShouldBe(
+ built,
+ $"Golden vector '{fileName}' no longer matches SparkplugGoldenPayloads. Either the builder or "
+ + $"the vendored proto changed. If the change is intended, regenerate with {RegenEnvVar}=1 and "
+ + "commit the .bin diff.");
+ }
+
+ /// This file's own source directory, resolved at compile time.
+ /// Supplied by the compiler; never pass it.
+ /// The directory holding the test sources.
+ private static string SourceDirectory([CallerFilePath] string path = "") => Path.GetDirectoryName(path)!;
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests.csproj
index f1a88765..d734cfed 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests.csproj
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests.csproj
@@ -25,4 +25,11 @@
+
+
+
+
+