feat(mqtt): read Sparkplug B array metrics as 1-D OPC UA arrays (lmxopcua#754)
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:
Joseph Doherty
2026-11-09 13:58:38 -05:00
parent 919009c5ef
commit 7f29a75edb
19 changed files with 1112 additions and 53 deletions
@@ -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.";
}