From 25897744808df8a574ce4e266456f55a75e80d3f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 21:09:14 -0400 Subject: [PATCH] feat(mqtt): SparkplugCodec decode + golden payload vectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decodes Sparkplug-B wire bytes into a driver-side projection (SparkplugPayload / SparkplugMetric / SparkplugValueKind) for the Task 21 ingest state machine. - Never throws, for ANY input. It sits on MQTTnet's shared dispatcher thread, so an escaping exception would stall delivery for every subscription on the connection, not one tag. Garbage, truncation, zero-length, a valid protobuf of another schema and an over-nested Template all resolve to a verdict. - Zero-length input is INVALID, not "a payload with no metrics" — protobuf would parse it as all-defaults, and that is how a truncated-to-nothing body gets mistaken for a well-formed one. - Explicit presence throughout (Has{Seq,Name,Alias,Datatype,Timestamp}), never a zero-check: an NBIRTH legitimately carries seq = 0, and every DATA metric after a birth carries an alias with no name and no datatype. - Values are projected RAW, boxed, with the value oneof reported as an explicit ValueKind — Absent / Null / Scalar / Unsupported all mean different things and three of them carry a null value. DataSet/Template/extension decode as Unsupported (v1 scope) rather than throwing or silently vanishing. - ReinterpretSigned() undoes Sparkplug's two's-complement-in-an-unsigned-field encoding of Int8/16/32/64. Kept out of decode because a DATA metric carries no datatype — only the consumer, holding the birth's alias table, knows which applies. Skipping it publishes 4294967254 for a tag whose value is -42. - Datatype is carried as the generated Org.Eclipse.Tahu.Protobuf.DataType; the SparkplugDataType map is Task 17's and is applied downstream (Task 21). Golden vectors: nbirth.bin (seq=0, named/aliased catalog, a negative Int32, an is_null metric, a DataSet metric) + ndata.bin (alias-only metrics, no name, no datatype) are hand-built by SparkplugGoldenPayloads, committed, and pinned by a drift guard that rebuilds and byte-compares them on every run — plus a test that they actually reach the output directory, since a missing copy item is invisible in source. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../Sparkplug/SparkplugCodec.cs | 311 ++++++++++++++ .../Golden/nbirth.bin | Bin 0 -> 218 bytes .../Golden/ndata.bin | Bin 0 -> 41 bytes .../SparkplugCodecTests.cs | 392 ++++++++++++++++++ .../SparkplugGoldenPayloads.cs | 216 ++++++++++ .../SparkplugGoldenVectorTests.cs | 87 ++++ ...ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests.csproj | 7 + 7 files changed, 1013 insertions(+) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SparkplugCodec.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/Golden/nbirth.bin create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/Golden/ndata.bin create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugCodecTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugGoldenPayloads.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugGoldenVectorTests.cs 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 0000000000000000000000000000000000000000..19c14ffe8e924b5f889b041ecdb9d3443bbb80d1 GIT binary patch literal 218 zcmd;RnDco_pOFwZ7i&^VaB86fM+Cc&6qkr!eoCr>bADb)QGSkoP-;?UQAvgZcL9Tt zEEji3YHmSlQDRAHQK|r|1jGOZ&Qu15HI72!TpR&Ksl~-0IW`5Z%(G{JASBX3h@Xo+ zs5CDxGcR3$9cU1vkOUW7aB5LzVvYcZ0#6l}tBbpl>EmYFWVr6AD7D8Rue#FCt!V<5q-#KOe{ +/// 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 @@ + + + + +