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 @@ + + + + +