2589774480
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
217 lines
8.6 KiB
C#
217 lines
8.6 KiB
C#
using Google.Protobuf;
|
|
using Org.Eclipse.Tahu.Protobuf;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
|
|
|
/// <summary>
|
|
/// Builds the two Sparkplug-B wire vectors committed under <c>Golden/</c> — an NBIRTH and the
|
|
/// NDATA that follows it — and names the files they live in.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Why a generator instead of bytes captured off a real edge node.</b> 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
|
|
/// <c>SparkplugGoldenVectorTests</c> 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Every field in here is load-bearing.</b> The NBIRTH carries <c>seq = 0</c> (present and
|
|
/// zero — the case a <c>Seq != 0</c> presence check gets wrong), a negative Int32 encoded the
|
|
/// Sparkplug way (two's complement in an unsigned <c>int_value</c>), an explicit
|
|
/// <c>is_null</c> metric, and a <c>DataSet</c> metric that v1 does not support. The NDATA
|
|
/// carries metrics with <b>no name and no datatype</b> — 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal static class SparkplugGoldenPayloads
|
|
{
|
|
/// <summary>Repo-relative directory the committed vectors live in, inside the test project.</summary>
|
|
public const string Directory = "Golden";
|
|
|
|
/// <summary>File name of the NBIRTH vector.</summary>
|
|
public const string NbirthFile = "nbirth.bin";
|
|
|
|
/// <summary>File name of the NDATA vector.</summary>
|
|
public const string NdataFile = "ndata.bin";
|
|
|
|
/// <summary>NBIRTH payload timestamp — 2024-07-24T12:00:00Z, in Sparkplug's epoch milliseconds.</summary>
|
|
public const ulong NbirthTimestampMs = 1721822400000UL;
|
|
|
|
/// <summary>NDATA payload timestamp — five seconds after the birth.</summary>
|
|
public const ulong NdataTimestampMs = 1721822405000UL;
|
|
|
|
/// <summary>The alias the NBIRTH binds to <c>Temperature</c>, reused by the NDATA.</summary>
|
|
public const ulong TemperatureAlias = 5UL;
|
|
|
|
/// <summary>The alias the NBIRTH binds to <c>Count</c> — the negative-Int32 case.</summary>
|
|
public const ulong CountAlias = 9UL;
|
|
|
|
/// <summary>The alias the NBIRTH binds to <c>Missing</c> — the explicit-null case.</summary>
|
|
public const ulong MissingAlias = 11UL;
|
|
|
|
/// <summary>The <c>Count</c> metric's value in the NBIRTH — negative, to pin the wire encoding.</summary>
|
|
public const int CountBirthValue = -42;
|
|
|
|
/// <summary>The <c>Count</c> metric's value in the NDATA.</summary>
|
|
public const int CountDataValue = -43;
|
|
|
|
/// <summary>
|
|
/// Builds the NBIRTH: <c>seq = 0</c>, a full metric catalog with names, aliases and datatypes.
|
|
/// </summary>
|
|
/// <returns>The payload; serialize with <see cref="MessageExtensions.ToByteArray"/>.</returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds the NDATA that follows <see cref="BuildNbirth"/>: <c>seq = 1</c> and metrics carrying
|
|
/// <b>alias only</b> — no name, no datatype — exactly as a real edge node publishes them.
|
|
/// </summary>
|
|
/// <returns>The payload; serialize with <see cref="MessageExtensions.ToByteArray"/>.</returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>Reads a committed vector from the test binary's output directory.</summary>
|
|
/// <param name="fileName"><see cref="NbirthFile"/> or <see cref="NdataFile"/>.</param>
|
|
/// <returns>The committed wire bytes.</returns>
|
|
public static byte[] Read(string fileName) => File.ReadAllBytes(PathTo(fileName));
|
|
|
|
/// <summary>Resolves a vector's path under the test binary's output directory.</summary>
|
|
/// <param name="fileName"><see cref="NbirthFile"/> or <see cref="NdataFile"/>.</param>
|
|
/// <returns>The absolute path.</returns>
|
|
/// <remarks>
|
|
/// Rooted at <see cref="AppContext.BaseDirectory"/> 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.
|
|
/// </remarks>
|
|
public static string PathTo(string fileName) =>
|
|
Path.Combine(AppContext.BaseDirectory, Directory, fileName);
|
|
}
|