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); }