feat(mqtt): read Sparkplug B array metrics as 1-D OPC UA arrays (lmxopcua#754)
v2-ci / unit-tests (push) Has been cancelled
v2-ci / unit-tests (push) Has been cancelled
R06:M-5 made Sparkplug *Array leaves unbindable because the ingestor refused them. The ingestor now decodes the packed bytes_value of all 13 array types (new SparkplugArrayCodec: LE fixed widths, int64-ms DateTime, uint32-count + MSB-first BooleanArray, NUL-terminated UTF-8 StringArray; golden vectors are the spec's examples, reproduced byte-for-byte by SparkplugNet 1.3.10) and publishes a typed 1-D array through the scalar coercion, all-or-nothing. Shape uses the fleet's one array model: the tag's platform isArray/arrayLength keys (ValueRank=1, ArrayDimensions=[arrayLength] or [0] = variable length). The driver reads them with TagConfigIntent's exact semantics and never rejects on them, so composer, driver, authoring model and deploy inspector agree (MqttArrayShapeParityTests). Shape mismatch (array metric on a scalar tag, or the reverse) is BadTypeMismatch - a String tag no longer gets the packed buffer as base64; a malformed body or a length differing from a declared arrayLength is BadDecodingError (MTConnect #627 verdict). Template/DataSet members of an array type take the same path. Arrays stay read-only (encoder refuses them; MQTT is not in DriverArrayWriteSupport). Browser: array leaves are bindable again and state isArray in AddressFields; the commit mapper writes "isArray": true (no arrayLength). Discovery reports IsArray/ArrayDim. Docs: Mqtt.md "Array metrics", design table, drivers rule. Tests: codec (golden + malformed), ingest through the real codec + Dispatch (the project's MiniBroker never forwards a PUBLISH, so there is no in-process broker harness), browse, discovery, commit mapper, parity corpus. Mutating the fix back out fails 18 MQTT + 9 AdminUI tests. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
@@ -456,20 +456,22 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
|
||||
var descriptor = new StringBuilder(metric.BirthType.ToString()).Append(" · ").Append(sparkplugName);
|
||||
if (metric.Alias is { } alias) descriptor.Append(" · alias ").Append(alias);
|
||||
if (mapped is null) descriptor.Append(" · unsupported in v1 — not bindable");
|
||||
else if (isArray) descriptor.Append(" · array metrics are not read in v1 — not bindable");
|
||||
else if (isArray) descriptor.Append(" · 1-D array");
|
||||
|
||||
// Unbindable = the ingestor refuses it: an unmapped type, or ANY array variant (SparkplugIngestor
|
||||
// gates on the array bit and never feeds the tag). Such a leaf states NO address, so the
|
||||
// Unbindable = the ingestor refuses it: an unmapped type. Such a leaf states NO address, so the
|
||||
// browse-commit refuses it at commit instead of writing a tag that sits at
|
||||
// BadWaitingForInitialData forever (R06:M-5). Labelling it alone was not enough — it still bound.
|
||||
var bindable = mapped is not null && !isArray;
|
||||
// An *Array variant IS bindable since #754 (the ingestor decodes it into a 1-D array) — but only to
|
||||
// an ARRAY tag, so its address states the array bit too and the commit writes "isArray": true.
|
||||
// Without it the committed tag would be scalar and read BadTypeMismatch forever: R06:M-5 again.
|
||||
var bindable = mapped is not null;
|
||||
|
||||
return new AttributeInfo(
|
||||
metric.Name,
|
||||
mapped?.ToString() ?? sparkplugName,
|
||||
isArray,
|
||||
descriptor.ToString(),
|
||||
AddressFields: bindable ? BuildMetricAddressFields(metric.Name, scope) : null);
|
||||
AddressFields: bindable ? BuildMetricAddressFields(metric.Name, scope, isArray) : null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -477,13 +479,15 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
|
||||
/// </summary>
|
||||
/// <param name="metricName">The metric's birth-declared name.</param>
|
||||
/// <param name="scope">The metric node's stamped identity, or <see langword="null"/>.</param>
|
||||
/// <param name="isArray">Whether the metric is an <c>*Array</c> variant; stated as
|
||||
/// <see cref="MqttTagConfigKeys.IsArray"/> = <c>"true"</c> so the commit writes an array tag (#754).</param>
|
||||
/// <returns>
|
||||
/// The address fields, or <see langword="null"/> when the node carries no scope (which a
|
||||
/// Sparkplug metric node never does — every one is created with the scope its birth parsed —
|
||||
/// so a null here is "the producer cannot state the address", never "the address is empty").
|
||||
/// </returns>
|
||||
private static IReadOnlyDictionary<string, string>? BuildMetricAddressFields(
|
||||
string metricName, SparkplugScope? scope)
|
||||
string metricName, SparkplugScope? scope, bool isArray)
|
||||
{
|
||||
if (scope?.EdgeNodeId is not { } edgeNodeId) return null;
|
||||
|
||||
@@ -497,6 +501,9 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
|
||||
// Omitted, not blank, for a node-level metric: the factory normalises "" to absent anyway, but
|
||||
// an emitted empty device id would read as "a device whose id is empty" to anything else looking.
|
||||
if (scope.DeviceId is { } deviceId) fields[MqttTagConfigKeys.DeviceId] = deviceId;
|
||||
|
||||
// Omitted for a scalar metric, for the same reason: absence is the factory's scalar default.
|
||||
if (isArray) fields[MqttTagConfigKeys.IsArray] = "true";
|
||||
return fields;
|
||||
}
|
||||
|
||||
|
||||
@@ -46,4 +46,13 @@ public static class MqttTagConfigKeys
|
||||
|
||||
/// <summary>The Sparkplug metric's stable name — the tag's binding key across rebirths.</summary>
|
||||
public const string MetricName = "metricName";
|
||||
|
||||
/// <summary>
|
||||
/// The platform's 1-D array key (the one the address-space composer reads for <c>ValueRank</c>). Not an
|
||||
/// address, but it is the one shape key a browse PRODUCES for this driver: a Sparkplug <c>*Array</c>
|
||||
/// metric binds only to an array tag (lmxopcua#754), so the browse session states it alongside the
|
||||
/// tuple and the commit mapper writes it as the JSON literal <see langword="true"/> — the same
|
||||
/// produce-here/consume-there drift risk the address keys exist to close.
|
||||
/// </summary>
|
||||
public const string IsArray = "isArray";
|
||||
}
|
||||
|
||||
@@ -76,6 +76,19 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
/// The Sparkplug metric name — the tag's stable identity across rebirths, and the key the ingest
|
||||
/// state machine binds by. <see langword="null"/> for a Plain-mode tag.
|
||||
/// </param>
|
||||
/// <param name="IsArray">
|
||||
/// Whether the tag is a 1-D array (<c>ValueRank=1</c>): the platform's <c>isArray</c> key, read exactly as
|
||||
/// the address-space composer reads it (<c>TagConfigIntent</c> — only the JSON literal <see langword="true"/>
|
||||
/// counts), so the driver and the node it feeds can never disagree on shape. Read in <b>Sparkplug</b> mode
|
||||
/// only, where it binds a tag to a Sparkplug <c>*Array</c> metric (lmxopcua#754); a scalar tag on an array
|
||||
/// metric, or an array tag on a scalar metric, reads <c>BadTypeMismatch</c>. Plain mode never sets it.
|
||||
/// </param>
|
||||
/// <param name="ArrayLength">
|
||||
/// The declared element count (<c>arrayLength</c>, honoured only on an array tag and only as a JSON
|
||||
/// number fitting <see cref="uint"/>, as the composer reads it), or <see langword="null"/> /
|
||||
/// <c>0</c> for a variable-length array (<c>ArrayDimensions=[0]</c>). A Sparkplug array whose element
|
||||
/// count differs from a declared length is refused rather than served under the wrong dimensions.
|
||||
/// </param>
|
||||
public sealed record MqttTagDefinition(
|
||||
string Name,
|
||||
string Topic,
|
||||
@@ -89,4 +102,6 @@ public sealed record MqttTagDefinition(
|
||||
string? EdgeNodeId = null,
|
||||
string? DeviceId = null,
|
||||
string? MetricName = null,
|
||||
string? WriteTopic = null);
|
||||
string? WriteTopic = null,
|
||||
bool IsArray = false,
|
||||
uint? ArrayLength = null);
|
||||
|
||||
@@ -241,6 +241,8 @@ public static class MqttTagDefinitionFactory
|
||||
// verdict on it must not depend on which reader took it.
|
||||
if (!TryReadBoolStrict(root, "retainSeed", defaultValue: true, out var retainSeed)) return false;
|
||||
|
||||
var (isArray, arrayLength) = ReadArrayShape(root);
|
||||
|
||||
def = new MqttTagDefinition(
|
||||
Name: rawPath,
|
||||
// Plain-shape fields carried through verbatim; the Sparkplug ingest path reads none of
|
||||
@@ -258,7 +260,13 @@ public static class MqttTagDefinitionFactory
|
||||
MetricName: metricName,
|
||||
// Carried through, but unread on the Sparkplug write path: a Sparkplug command is
|
||||
// addressed by the binding tuple, not by a topic the operator typed.
|
||||
WriteTopic: NullIfBlank(ReadString(root, MqttTagConfigKeys.WriteTopic)));
|
||||
WriteTopic: NullIfBlank(ReadString(root, MqttTagConfigKeys.WriteTopic)),
|
||||
// The array shape (#754), read LENIENTLY and exactly as the address-space composer reads it
|
||||
// (TagConfigIntent): the node's ValueRank comes from that reader, so a stricter one here
|
||||
// would refuse a tag whose node still exists (BadNodeIdUnknown on a live node), and a looser
|
||||
// one would feed an array into a scalar node. Never a rejection cause.
|
||||
IsArray: isArray,
|
||||
ArrayLength: arrayLength);
|
||||
return true;
|
||||
}
|
||||
catch (JsonException) { return false; }
|
||||
@@ -513,6 +521,30 @@ public static class MqttTagDefinitionFactory
|
||||
return $"value '{e.GetRawText()}' for 'qos' is not a valid MQTT QoS; valid: 0, 1, 2";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the platform array-shape keys with <c>TagConfigIntent.ParseArray</c>'s exact semantics: an
|
||||
/// array only when <c>isArray</c> is the JSON literal <see langword="true"/>; a length only on an array,
|
||||
/// and only when <c>arrayLength</c> is a JSON number fitting <see cref="uint"/>. That parser lives in
|
||||
/// Commons, which this transport-free Contracts assembly does not reference; the MQTT parity tests pin
|
||||
/// the two against each other so the copy cannot drift.
|
||||
/// </summary>
|
||||
/// <param name="root">The TagConfig root object.</param>
|
||||
/// <returns>The array bit and the declared length (<see langword="null"/> when absent or not honoured).</returns>
|
||||
private static (bool IsArray, uint? ArrayLength) ReadArrayShape(JsonElement root)
|
||||
{
|
||||
var isArray = root.TryGetProperty(MqttTagConfigKeys.IsArray, out var a) && a.ValueKind == JsonValueKind.True;
|
||||
uint? length = null;
|
||||
if (isArray
|
||||
&& root.TryGetProperty("arrayLength", out var l)
|
||||
&& l.ValueKind == JsonValueKind.Number
|
||||
&& l.TryGetUInt32(out var n))
|
||||
{
|
||||
length = n;
|
||||
}
|
||||
|
||||
return (isArray, length);
|
||||
}
|
||||
|
||||
private static string ReadString(JsonElement o, string name)
|
||||
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String
|
||||
? e.GetString() ?? ""
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using TahuDataType = Org.Eclipse.Tahu.Protobuf.DataType;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
|
||||
|
||||
/// <summary>
|
||||
/// Decodes the packed <c>bytes_value</c> of a Sparkplug B <c>*Array</c> metric into its elements
|
||||
/// (lmxopcua#754, R06:M-5).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The wire format (Sparkplug B 3.0, "Datatype details").</b> Every array rides in the metric's
|
||||
/// <c>bytes_value</c>, packed little-endian with no header: <c>Int8</c>/<c>UInt8</c> one byte per
|
||||
/// element, <c>Int16</c>/<c>UInt16</c> two, <c>Int32</c>/<c>UInt32</c>/<c>Float</c> four,
|
||||
/// <c>Int64</c>/<c>UInt64</c>/<c>Double</c> eight, <c>DateTime</c> eight (signed epoch milliseconds).
|
||||
/// Two types differ: a <c>BooleanArray</c> is a little-endian <c>uint32</c> element count followed by
|
||||
/// the bits packed eight per byte, the FIRST element in the MOST significant bit, the last byte
|
||||
/// zero-padded; a <c>StringArray</c> is the UTF-8 strings each terminated by <c>0x00</c>. The unit
|
||||
/// tests pin the spec's own examples, which an independent encoder (SparkplugNet 1.3.10) reproduces
|
||||
/// byte for byte.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>All-or-nothing.</b> A body whose length is not a whole number of elements, a Boolean count the
|
||||
/// body cannot hold (or a body longer than the count needs), an unterminated last string or invalid
|
||||
/// UTF-8 is malformed, and NOTHING is returned: a partial vector served as Good is a wrong value,
|
||||
/// which is worse than none (the MTConnect TIME_SERIES rule, #627).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Elements keep their wire type</b> (<see cref="sbyte"/> for <c>Int8</c>, <see cref="byte"/> for
|
||||
/// <c>UInt8</c>, <see cref="long"/> epoch milliseconds for <c>DateTime</c>, …). Coercing them onto the
|
||||
/// tag's effective type is the ingestor's job, element by element, through the same coercion the
|
||||
/// scalar path uses — so an array element and a scalar of the same value cannot disagree.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class SparkplugArrayCodec
|
||||
{
|
||||
/// <summary>Decodes one packed array body.</summary>
|
||||
/// <param name="dataType">The metric's birth-declared datatype; must be one of the 13 <c>*Array</c> variants.</param>
|
||||
/// <param name="packed">The metric's <c>bytes_value</c>.</param>
|
||||
/// <param name="elements">The decoded elements on success; <see langword="null"/> otherwise.</param>
|
||||
/// <param name="error">Why the body was refused, when this returns <see langword="false"/>; never plant data.</param>
|
||||
/// <returns><see langword="true"/> when the whole body decoded.</returns>
|
||||
public static bool TryDecode(TahuDataType dataType, ReadOnlySpan<byte> packed, out Array? elements, out string? error)
|
||||
{
|
||||
elements = null;
|
||||
error = null;
|
||||
|
||||
switch (dataType)
|
||||
{
|
||||
case TahuDataType.Int8Array:
|
||||
return Fixed(packed, 1, s => (sbyte)s[0], out elements, out error);
|
||||
case TahuDataType.Uint8Array:
|
||||
return Fixed(packed, 1, s => s[0], out elements, out error);
|
||||
case TahuDataType.Int16Array:
|
||||
return Fixed(packed, 2, BinaryPrimitives.ReadInt16LittleEndian, out elements, out error);
|
||||
case TahuDataType.Uint16Array:
|
||||
return Fixed(packed, 2, BinaryPrimitives.ReadUInt16LittleEndian, out elements, out error);
|
||||
case TahuDataType.Int32Array:
|
||||
return Fixed(packed, 4, BinaryPrimitives.ReadInt32LittleEndian, out elements, out error);
|
||||
case TahuDataType.Uint32Array:
|
||||
return Fixed(packed, 4, BinaryPrimitives.ReadUInt32LittleEndian, out elements, out error);
|
||||
case TahuDataType.Int64Array:
|
||||
case TahuDataType.DateTimeArray:
|
||||
return Fixed(packed, 8, BinaryPrimitives.ReadInt64LittleEndian, out elements, out error);
|
||||
case TahuDataType.Uint64Array:
|
||||
return Fixed(packed, 8, BinaryPrimitives.ReadUInt64LittleEndian, out elements, out error);
|
||||
case TahuDataType.FloatArray:
|
||||
return Fixed(packed, 4, BinaryPrimitives.ReadSingleLittleEndian, out elements, out error);
|
||||
case TahuDataType.DoubleArray:
|
||||
return Fixed(packed, 8, BinaryPrimitives.ReadDoubleLittleEndian, out elements, out error);
|
||||
case TahuDataType.BooleanArray:
|
||||
return Booleans(packed, out elements, out error);
|
||||
case TahuDataType.StringArray:
|
||||
return Strings(packed, out elements, out error);
|
||||
default:
|
||||
error = $"{dataType} is not a Sparkplug array datatype";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private delegate T ReadElement<out T>(ReadOnlySpan<byte> source);
|
||||
|
||||
private static bool Fixed<T>(
|
||||
ReadOnlySpan<byte> packed, int width, ReadElement<T> read, out Array? elements, out string? error)
|
||||
{
|
||||
elements = null;
|
||||
error = null;
|
||||
if (packed.Length % width != 0)
|
||||
{
|
||||
error = $"{packed.Length} bytes is not a whole number of {width}-byte elements";
|
||||
return false;
|
||||
}
|
||||
|
||||
var result = new T[packed.Length / width];
|
||||
for (var i = 0; i < result.Length; i++)
|
||||
{
|
||||
result[i] = read(packed.Slice(i * width, width));
|
||||
}
|
||||
|
||||
elements = result;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool Booleans(ReadOnlySpan<byte> packed, out Array? elements, out string? error)
|
||||
{
|
||||
elements = null;
|
||||
error = null;
|
||||
if (packed.Length < 4)
|
||||
{
|
||||
error = $"{packed.Length} bytes cannot hold a BooleanArray's 4-byte element count";
|
||||
return false;
|
||||
}
|
||||
|
||||
var count = BinaryPrimitives.ReadUInt32LittleEndian(packed);
|
||||
var bits = packed[4..];
|
||||
|
||||
// Exact, not "at least": a body longer than its count needs is as malformed as a shorter one, and
|
||||
// taking the count on trust would let a corrupt header select an arbitrary prefix of the bits.
|
||||
if ((ulong)bits.Length != (count + 7UL) / 8UL)
|
||||
{
|
||||
error = $"a BooleanArray of {count} element(s) needs {(count + 7UL) / 8UL} packed byte(s), got {bits.Length}";
|
||||
return false;
|
||||
}
|
||||
|
||||
var result = new bool[count];
|
||||
for (var i = 0; i < result.Length; i++)
|
||||
{
|
||||
// First element in the most significant bit (the spec's example: 0x34 = F,F,T,T,F,T,F,F).
|
||||
result[i] = (bits[i >> 3] & (0x80 >> (i & 7))) != 0;
|
||||
}
|
||||
|
||||
elements = result;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static readonly UTF8Encoding StrictUtf8 = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
|
||||
|
||||
private static bool Strings(ReadOnlySpan<byte> packed, out Array? elements, out string? error)
|
||||
{
|
||||
elements = null;
|
||||
error = null;
|
||||
if (packed.Length > 0 && packed[^1] != 0)
|
||||
{
|
||||
error = "the last string of a StringArray is not 0x00-terminated";
|
||||
return false;
|
||||
}
|
||||
|
||||
var result = new List<string>();
|
||||
var rest = packed;
|
||||
try
|
||||
{
|
||||
while (!rest.IsEmpty)
|
||||
{
|
||||
var end = rest.IndexOf((byte)0);
|
||||
result.Add(StrictUtf8.GetString(rest[..end]));
|
||||
rest = rest[(end + 1)..];
|
||||
}
|
||||
}
|
||||
catch (DecoderFallbackException)
|
||||
{
|
||||
error = "a StringArray element is not valid UTF-8";
|
||||
return false;
|
||||
}
|
||||
|
||||
elements = result.ToArray();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -526,8 +526,10 @@ public sealed class MqttDriver
|
||||
folder.Variable(def.Name, def.Name, new DriverAttributeInfo(
|
||||
FullName: def.Name,
|
||||
DriverDataType: ResolveDiscoveredDataType(def),
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
// The authored array shape (#754, Sparkplug only), as the composer materialises it:
|
||||
// element DataType + ValueRank=1, a declared length or variable-length (null).
|
||||
IsArray: def.IsArray,
|
||||
ArrayDim: def.IsArray && def.ArrayLength is > 0u ? def.ArrayLength : null,
|
||||
// MQTT now has a write leg, so a tag that declares a write destination is
|
||||
// an Operate node. One that does not stays ViewOnly — the point of the distinction is
|
||||
// that a node must never look writable while its writes would go nowhere.
|
||||
|
||||
@@ -126,6 +126,9 @@ public sealed class SparkplugIngestor
|
||||
/// <summary>The metric's value does not fit the tag's effective data type — including an explicit Sparkplug null.</summary>
|
||||
private const uint StatusBadTypeMismatch = 0x80740000u;
|
||||
|
||||
/// <summary>A Sparkplug <c>*Array</c> body that is not a whole, well-formed vector (lmxopcua#754).</summary>
|
||||
private const uint StatusBadDecodingError = 0x80070000u;
|
||||
|
||||
/// <summary>The subscribed reference is not an authored Sparkplug tag.</summary>
|
||||
private const uint StatusBadNodeIdUnknown = 0x80340000u;
|
||||
|
||||
@@ -1270,25 +1273,6 @@ public sealed class SparkplugIngestor
|
||||
return; // A published metric nobody authored a tag for. Not an error — that is most of them.
|
||||
}
|
||||
|
||||
// Arrays slip past the unsupported-type gate below, because ToDriverDataType maps every
|
||||
// *Array variant to its ELEMENT type and is therefore never null for one. Design §3.5 defers
|
||||
// array support, and the failure is not benign: the value rides in bytes_value as a byte[] at
|
||||
// ValueKind.Scalar, so a numeric target throws its way to BadTypeMismatch (safe, wrong reason)
|
||||
// while a String/Reference target reaches Convert.ToBase64String and publishes a packed binary
|
||||
// buffer as a plausible string at GOOD quality. Gate on the array bit itself.
|
||||
if (binding.DataType.IsSparkplugArray())
|
||||
{
|
||||
WarnOnce(
|
||||
$"array-type:{key}",
|
||||
"MQTT driver '{DriverId}': metric '{Metric}' on '{Scope}' is a Sparkplug array "
|
||||
+ "({DataType}); array metrics are not supported in v1 and its tag(s) are not being fed.",
|
||||
_driverId,
|
||||
binding.Name,
|
||||
scope,
|
||||
binding.DataType);
|
||||
return;
|
||||
}
|
||||
|
||||
var birthType = binding.DataType.ToDriverDataType();
|
||||
if (birthType is null)
|
||||
{
|
||||
@@ -1308,12 +1292,24 @@ public sealed class SparkplugIngestor
|
||||
|
||||
var serverUtc = _utcNow();
|
||||
|
||||
// An *Array metric (#754). ToDriverDataType maps every array variant to its ELEMENT type, so the
|
||||
// birth type above is the element type and the array bit is read separately. The packed body is
|
||||
// decoded ONCE per metric, not per tag: every tag bound to it sees the same vector or the same
|
||||
// refusal. A scalar tag never gets this path's value — that used to publish the packed buffer as
|
||||
// base64 at GOOD quality on a String tag; it is now BadTypeMismatch (see BuildSnapshot).
|
||||
var isArrayMetric = binding.DataType.IsSparkplugArray();
|
||||
var decoded = isArrayMetric ? DecodeArray(binding, metric) : default;
|
||||
|
||||
foreach (var def in defs)
|
||||
{
|
||||
// The authored dataType wins when the operator declared one; otherwise the birth's own
|
||||
// declaration does. That is what lets a tag be authored with nothing but the metric tuple.
|
||||
// For an array tag it is the ELEMENT type, exactly as the composer's DataType is.
|
||||
var target = def.DataTypeAuthored ? def.DataType : birthType.Value;
|
||||
if (BuildSnapshot(def, binding, metric, target, sourceUtc, serverUtc) is not { } snapshot)
|
||||
var built = isArrayMetric
|
||||
? BuildArraySnapshot(def, binding, metric, decoded, target, sourceUtc, serverUtc)
|
||||
: BuildSnapshot(def, binding, metric, target, sourceUtc, serverUtc);
|
||||
if (built is not { } snapshot)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -1363,6 +1359,14 @@ public sealed class SparkplugIngestor
|
||||
return null;
|
||||
}
|
||||
|
||||
// A scalar metric on an ARRAY tag (#754): the node is ValueRank=1 and a scalar is not a value of
|
||||
// it. Refused rather than wrapped in a one-element array — the shapes disagree, and guessing which
|
||||
// side is right would hide an authoring error behind a Good value.
|
||||
if (def.IsArray)
|
||||
{
|
||||
return ShapeMismatch(def, binding, stamp, serverUtc);
|
||||
}
|
||||
|
||||
// THE call. Sparkplug carries signed integers as two's complement in an unsigned field, and a
|
||||
// DATA metric carries no datatype of its own — only the birth binding knows which applies.
|
||||
// Skipping this publishes 4294967254 for a value of -42: plausible, Good, and wrong.
|
||||
@@ -1373,6 +1377,169 @@ public sealed class SparkplugIngestor
|
||||
: BadTypeMismatch(def, binding, target, stamp, serverUtc);
|
||||
}
|
||||
|
||||
/// <summary>The outcome of decoding one array metric's packed body, shared by every tag bound to it.</summary>
|
||||
/// <param name="Elements">The decoded elements (wire-typed), or <see langword="null"/> when there are none.</param>
|
||||
/// <param name="Malformed">Whether the metric carried a body that is not a well-formed vector.</param>
|
||||
private readonly record struct DecodedArray(Array? Elements, bool Malformed);
|
||||
|
||||
/// <summary>
|
||||
/// Decodes an <c>*Array</c> metric's packed <c>bytes_value</c> once, for every tag bound to it. A
|
||||
/// value-less metric (null / absent / a non-bytes value) is not decoded here — the per-tag build reports
|
||||
/// it with the same verdicts the scalar path reaches.
|
||||
/// </summary>
|
||||
private DecodedArray DecodeArray(SparkplugMetricBinding binding, SparkplugMetric metric)
|
||||
{
|
||||
if (metric.ValueKind != SparkplugValueKind.Scalar || metric.Value is not byte[] packed)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
if (SparkplugArrayCodec.TryDecode(binding.DataType, packed, out var elements, out var error))
|
||||
{
|
||||
return new DecodedArray(elements, Malformed: false);
|
||||
}
|
||||
|
||||
WarnOnce(
|
||||
$"array-decode:{binding.Name}",
|
||||
"MQTT driver '{DriverId}': array metric '{Metric}' ({DataType}) carried a malformed body ({Reason}); "
|
||||
+ "its tag(s) read BadDecodingError until a well-formed value arrives.",
|
||||
_driverId,
|
||||
binding.Name,
|
||||
binding.DataType,
|
||||
error);
|
||||
return new DecodedArray(null, Malformed: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns one decoded array metric into a snapshot for one authored tag (#754): a 1-D array of the tag's
|
||||
/// effective element type, all-or-nothing, or a Bad status that says why not. Returns
|
||||
/// <see langword="null"/> when the metric carried no value at all, exactly as <see cref="BuildSnapshot"/>.
|
||||
/// </summary>
|
||||
private DataValueSnapshot? BuildArraySnapshot(
|
||||
MqttTagDefinition def,
|
||||
SparkplugMetricBinding binding,
|
||||
SparkplugMetric metric,
|
||||
DecodedArray decoded,
|
||||
DriverDataType target,
|
||||
DateTime? sourceUtc,
|
||||
DateTime serverUtc)
|
||||
{
|
||||
var stamp = ToUtc(metric.TimestampMs) ?? sourceUtc;
|
||||
|
||||
switch (metric.ValueKind)
|
||||
{
|
||||
case SparkplugValueKind.Scalar:
|
||||
break;
|
||||
|
||||
case SparkplugValueKind.Null:
|
||||
return new DataValueSnapshot(null, StatusBadTypeMismatch, stamp, serverUtc);
|
||||
|
||||
default:
|
||||
WarnOnce(
|
||||
$"valuekind:{def.Name}",
|
||||
"MQTT driver '{DriverId}': metric '{Metric}' for '{RawPath}' carried {ValueKind} rather "
|
||||
+ "than a value; skipped.",
|
||||
_driverId,
|
||||
binding.Name,
|
||||
def.Name,
|
||||
metric.ValueKind);
|
||||
return null;
|
||||
}
|
||||
|
||||
// An array metric on a SCALAR tag: the node is ValueRank=Scalar, and no single element is "the" value.
|
||||
if (!def.IsArray)
|
||||
{
|
||||
return ShapeMismatch(def, binding, stamp, serverUtc);
|
||||
}
|
||||
|
||||
if (decoded.Malformed)
|
||||
{
|
||||
return new DataValueSnapshot(null, StatusBadDecodingError, stamp, serverUtc);
|
||||
}
|
||||
|
||||
// A value that is not a packed body at all (an int_value on an Int32Array metric): it does not fit.
|
||||
if (decoded.Elements is not { } elements)
|
||||
{
|
||||
return BadTypeMismatch(def, binding, target, stamp, serverUtc);
|
||||
}
|
||||
|
||||
// A declared length is the node's ArrayDimensions; a vector of another length is not a value of that
|
||||
// node. BadDecodingError, the verdict MTConnect gives a TIME_SERIES vector that disagrees with a fixed
|
||||
// ArrayDim (#627). Null / 0 = variable length, any count fits.
|
||||
if (def.ArrayLength is { } declared && declared > 0 && elements.Length != declared)
|
||||
{
|
||||
WarnOnce(
|
||||
$"array-length:{def.Name}",
|
||||
"MQTT driver '{DriverId}': array metric '{Metric}' carried {Count} element(s) but tag '{RawPath}' "
|
||||
+ "declares arrayLength {Declared}; status BadDecodingError.",
|
||||
_driverId,
|
||||
binding.Name,
|
||||
elements.Length,
|
||||
def.Name,
|
||||
declared);
|
||||
return new DataValueSnapshot(null, StatusBadDecodingError, stamp, serverUtc);
|
||||
}
|
||||
|
||||
if (ElementClrType(target) is not { } clrType)
|
||||
{
|
||||
return BadTypeMismatch(def, binding, target, stamp, serverUtc);
|
||||
}
|
||||
|
||||
// Element by element through the SCALAR coercion, all-or-nothing: one element that does not fit the
|
||||
// effective type refuses the whole vector (a partial array served as Good is a wrong value).
|
||||
var result = Array.CreateInstance(clrType, elements.Length);
|
||||
for (var i = 0; i < elements.Length; i++)
|
||||
{
|
||||
if (!SparkplugValueCoercion.TryCoerce(elements.GetValue(i), target, out var element))
|
||||
{
|
||||
return BadTypeMismatch(def, binding, target, stamp, serverUtc);
|
||||
}
|
||||
|
||||
result.SetValue(element, i);
|
||||
}
|
||||
|
||||
return new DataValueSnapshot(result, StatusGood, stamp, serverUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The CLR element type <see cref="SparkplugValueCoercion.TryCoerce"/> produces for
|
||||
/// <paramref name="target"/> — the element type of the array a tag of that type is served as.
|
||||
/// </summary>
|
||||
private static Type? ElementClrType(DriverDataType target) => target switch
|
||||
{
|
||||
DriverDataType.Boolean => typeof(bool),
|
||||
DriverDataType.Int16 => typeof(short),
|
||||
DriverDataType.Int32 => typeof(int),
|
||||
DriverDataType.Int64 => typeof(long),
|
||||
DriverDataType.UInt16 => typeof(ushort),
|
||||
DriverDataType.UInt32 => typeof(uint),
|
||||
DriverDataType.UInt64 => typeof(ulong),
|
||||
DriverDataType.Float32 => typeof(float),
|
||||
DriverDataType.Float64 => typeof(double),
|
||||
DriverDataType.String or DriverDataType.Reference => typeof(string),
|
||||
DriverDataType.DateTime => typeof(DateTime),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The metric's shape (array or scalar) disagrees with the tag's (<c>isArray</c>). Warned once per tag
|
||||
/// with the fix, since this is always an authoring error, never a wire one.
|
||||
/// </summary>
|
||||
private DataValueSnapshot ShapeMismatch(
|
||||
MqttTagDefinition def, SparkplugMetricBinding binding, DateTime? stamp, DateTime serverUtc)
|
||||
{
|
||||
WarnOnce(
|
||||
$"shape:{def.Name}",
|
||||
"MQTT driver '{DriverId}': metric '{Metric}' is a Sparkplug {DataType} but tag '{RawPath}' is "
|
||||
+ "{TagShape}; status BadTypeMismatch. Make the tag's \"isArray\" match the metric.",
|
||||
_driverId,
|
||||
binding.Name,
|
||||
binding.DataType,
|
||||
def.Name,
|
||||
def.IsArray ? "an array (isArray: true)" : "a scalar (no isArray)");
|
||||
return new DataValueSnapshot(null, StatusBadTypeMismatch, stamp, serverUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes each flattened member of a structured (<c>Template</c> / <c>DataSet</c>) metric as
|
||||
/// the sibling metric <c>{parent}/{memberPath}</c>.
|
||||
@@ -1380,7 +1547,7 @@ public sealed class SparkplugIngestor
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Each member is re-entered through <see cref="PublishMetric"/> as a scalar</b>, rather
|
||||
/// than duplicating the coercion, array-gate and authored-tag fan-out here. A second copy of
|
||||
/// than duplicating the coercion, array decode and authored-tag fan-out here. A second copy of
|
||||
/// that logic would be free to drift — and the drift would be silent, since both paths
|
||||
/// publish values that look plausible.
|
||||
/// </para>
|
||||
|
||||
@@ -351,6 +351,12 @@ public static class RawBrowseCommitMapper
|
||||
if (TryGetField(addressFields, MqttTagConfigKeys.DeviceId) is { } deviceId)
|
||||
o[MqttTagConfigKeys.DeviceId] = deviceId;
|
||||
|
||||
// A Sparkplug *Array metric binds only to an ARRAY tag (#754): the session states the bit, and it
|
||||
// is written as the JSON literal true — the only form the composer and the driver both read as an
|
||||
// array. No arrayLength: a Sparkplug array is variable-length, so the node is ArrayDimensions=[0].
|
||||
if (string.Equals(TryGetField(addressFields, MqttTagConfigKeys.IsArray), "true", StringComparison.OrdinalIgnoreCase))
|
||||
o[MqttTagConfigKeys.IsArray] = true;
|
||||
|
||||
return o.ToJsonString();
|
||||
}
|
||||
|
||||
@@ -422,12 +428,12 @@ public static class RawBrowseCommitMapper
|
||||
if (TryGetField(addressFields, MqttTagConfigKeys.Topic) is not null) return null;
|
||||
|
||||
// G2-F2: the Sparkplug browser deliberately states no address for a metric the ingestor will not
|
||||
// read (an *Array type, or a type with no driver mapping — R06:M-5), and says so on its descriptor
|
||||
// read (a type with no driver mapping — R06:M-5; *Array types are read since #754), and says so on its descriptor
|
||||
// line. That is a "not bindable in this version" refusal, not a failed lookup, so say which.
|
||||
if (descriptor is not null && descriptor.Contains(NotBindableMarker, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return $"'{browseName}' cannot be bound in this version: the Sparkplug browser reports it as "
|
||||
+ $"\"{descriptor}\". Array metrics and metrics of an unsupported type are not read yet; "
|
||||
+ $"\"{descriptor}\". Metrics of an unsupported type are not read yet; "
|
||||
+ "remove it from the selection.";
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user