diff --git a/.claude/rules/drivers.md b/.claude/rules/drivers.md
index bf69b55e..6fe44613 100644
--- a/.claude/rules/drivers.md
+++ b/.claude/rules/drivers.md
@@ -199,7 +199,10 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa
refused (a one-byte-per-element encode would clobber neighbour bits).
- **MQTT:** Plain `writeTopic` writes publish at QoS 1 (Sparkplug NCMD/DCMD stay QoS 0); the Sparkplug
primary-host claim backs off 2 s → 30 s; writes encode the birth's own datatype (unsupported birth types
- `BadNotSupported`).
+ `BadNotSupported`). Sparkplug `*Array` metrics read as 1-D arrays onto `"isArray": true` tags only (#754 —
+ shape mismatch is `BadTypeMismatch`, malformed body / wrong declared length `BadDecodingError`); read-only.
+ The driver reads `isArray`/`arrayLength` with `TagConfigIntent`'s exact semantics — keep them in step
+ (`MqttArrayShapeParityTests`).
- **No driver-side history (#655):** `IHistoryProvider`, `DriverCapability.HistoryRead`, `HistoryReadStatusException` and
the OpcUaClient history passthrough are deleted — nothing ever dispatched HistoryRead to a driver; it is served only by the
server-wide `IHistorianDataSource`. `isHistorized` on an OpcUaClient tag means OtOpcUa records it, not that upstream
diff --git a/docs/drivers/Mqtt.md b/docs/drivers/Mqtt.md
index c205fd41..86294d90 100644
--- a/docs/drivers/Mqtt.md
+++ b/docs/drivers/Mqtt.md
@@ -210,6 +210,7 @@ survive a reconnect; a message fans out to *every* matching tag, and unauthored
| `metricName` | string | — | **Required.** The tag's stable binding key across rebirths. **May contain `/`** — `Node Control/Rebirth` and `Properties/Serial Number` are canonical Sparkplug names, and the whole name is one value, never split. Also how a flattened structured member is addressed: `Motor/Speed`, `Recipe/Target/0` (see [Structured metrics](#structured-metrics-template--dataset--propertyset)) |
| `dataType` | `DriverDataType` | *from the birth* | **Optional override.** Absent ⇒ take whatever type the birth certificate declared. The AdminUI's "(from birth certificate)" option removes the key entirely |
| `isHistorized` / `historianTagname` | — | — | Server-side historization, unchanged from Plain |
+| `isArray` / `arrayLength` | bool / uint | *absent* (scalar) | **Required `true` for a `*Array` metric** — see [Array metrics](#array-metrics-754). `arrayLength` is optional (absent ⇒ variable length) |
`payloadFormat`, `jsonPath`, `qos` and `retainSeed` are **Plain-only** and meaningless here: a
Sparkplug body is protobuf, and the subscription is one driver-wide `spBv1.0/{GroupId}/#`, not a
@@ -252,13 +253,45 @@ One subscription (`spBv1.0/{GroupId}/#`) feeds a per-edge-node state machine
[Structured metrics](#structured-metrics-template--dataset--propertyset).
- **Unsupported datatypes are skipped with a warning, never guessed**: `PropertySetList`, `Unknown`,
extension values, and any `Template`/`DataSet` that cannot be flattened honestly. `Int8`→`Int16` and
- `UInt8`→`UInt16` widen; `*Array` variants map to the element type with the array bit set — but
- **array metrics are not read in v1** (warned once, never fed; → #754), so the browse picker offers them, and any
- metric whose type is unsupported, **without an address**: the browse-commit refuses them rather than
- committing a tag that waits for initial data forever (R06:M-5).
+ `UInt8`→`UInt16` widen; `*Array` variants map to the element type with the array bit set and are read as
+ **1-D OPC UA arrays** (see [Array metrics](#array-metrics-754)). The browse picker offers a metric whose type
+ is unsupported **without an address**: the browse-commit refuses it rather than committing a tag that waits
+ for initial data forever (R06:M-5).
- **Ingest state is reset at every session/authoring seam** — a reconnect or a `ReinitializeAsync`
must not carry a previous session's alias table into a new one.
+### Array metrics (#754)
+
+The 13 Sparkplug `*Array` datatypes (`Int8Array`…`UInt64Array`, `FloatArray`, `DoubleArray`, `BooleanArray`,
+`StringArray`, `DateTimeArray`) are decoded from the metric's packed `bytes_value` into a **1-D array** of the
+tag's element type (`SparkplugArrayCodec`), using the fleet's one array model: the tag carries the platform
+`"isArray": true` key, so the node is `ValueRank = 1` with the element `DataType`, and `ArrayDimensions` is
+`[arrayLength]` when one is declared or `[0]` (variable length) when not — a Sparkplug array's length can change
+from one DATA message to the next, so the browse-commit declares none.
+
+- **Wire format** (Sparkplug B 3.0): little-endian, no header — 1/2/4/8 bytes per element; `DateTimeArray` is
+ int64 epoch ms; `BooleanArray` is a uint32 LE count then the bits packed **most-significant-bit first**;
+ `StringArray` is NUL-terminated UTF-8. The tests pin the spec's own examples, which SparkplugNet reproduces.
+- **Element type:** the authored `dataType` when present, else the birth's element type (`Int8`/`UInt8` widen to
+ `Int16`/`UInt16` as for scalars). Every element goes through the scalar coercion, **all-or-nothing**.
+- **Shape must match.** An array metric on a tag without `isArray`, or a scalar metric on an `isArray` tag, is
+ `BadTypeMismatch` (warned once, naming the fix) — never the packed buffer, which a String tag used to receive
+ as base64 at Good.
+- **Refusals:** a malformed body (not a whole number of elements, a Boolean count the body cannot hold,
+ an unterminated string, invalid UTF-8) or a vector whose length differs from a declared `arrayLength` is
+ `BadDecodingError` (MTConnect's TIME_SERIES verdict, #627); an element that does not fit the effective type,
+ an explicit null, or a non-bytes value is `BadTypeMismatch`.
+- **Read-only.** Array writes are not encoded (`MqttWriteEncoder` refuses `*Array` births) and MQTT is not in
+ `DriverArrayWriteSupport`, so the node is materialised read-only even on a `ReadWrite` tag.
+- **The array keys are read exactly as the composer reads them** (`isArray` only as the literal `true`;
+ `arrayLength` only as a uint number) and are never a rejection cause, so authoring, deploy gate, node shape
+ and driver agree (`MqttArrayShapeParityTests`). Template/DataSet members of an array type decode the same way.
+
+```jsonc
+// A variable-length array metric (what a browse-commit writes).
+{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA","deviceId":"Filler1","metricName":"Profile","isArray":true}
+```
+
### Rediscovery (Sparkplug)
`OnRediscoveryNeeded` fires only when a birth is **for an authored scope** *and* its metric-name set
@@ -595,8 +628,9 @@ The bespoke browser wins over the universal `DiscoveryDriverBrowser`, which woul
A committed leaf's address is a **descriptor**, not a single reference string, so
`RawBrowseCommitMapper` reads it from the `AttributeInfo.AddressFields` the session states — Plain
-emits `topic`; Sparkplug emits `groupId` / `edgeNodeId` / `deviceId?` / `metricName`, the names
-`MqttTagConfigKeys` single-sources for both the mapper and `MqttTagDefinitionFactory`.
+emits `topic`; Sparkplug emits `groupId` / `edgeNodeId` / `deviceId?` / `metricName` — plus `isArray` for a
+`*Array` metric, written as the JSON literal `true` (#754) — the names `MqttTagConfigKeys` single-sources for
+both the mapper and `MqttTagDefinitionFactory`.
**It is never parsed out of the browse node id.** A Sparkplug node id is
`{group}/{node}[/{device}]::{metric}` and a metric name legitimately contains `/`
diff --git a/docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md b/docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md
index 4a63db24..1ced3d4d 100644
--- a/docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md
+++ b/docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md
@@ -148,7 +148,7 @@ Sparkplug metric datatype (or inferred/authored plain type) → `DriverDataType`
| DateTime | `DateTime` | DateTime |
| Bytes, File | `String` (base64/raw fallback, v1) | String |
| DataSet, Template | **unsupported v1** — skip + warn, or expose raw-JSON as `String` | — |
-| `*Array` variants | element type + `IsArray=true`, `ArrayDim` from birth | ValueRank=1 |
+| `*Array` variants | element type + `IsArray=true`; ArrayDimensions from the tag's `arrayLength` (absent ⇒ variable length, `[0]`) — implemented #754, see `docs/drivers/Mqtt.md` §"Array metrics" | ValueRank=1 |
Live map in `SparkplugDataType.ToDriverDataType()` (`.Contracts`). **Plain mode:** honour an explicit per-tag `dataType` (preferred — inference is brittle); fall back to inferring from the JSON token at `jsonPath` (number→`Float64`/`Int64`, bool→`Boolean`, string→`String`). Int8/UInt8 widen (no OPC UA signed-byte distinction needed).
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs
index 1b6b691c..5e05f791 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs
@@ -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);
}
///
@@ -477,13 +479,15 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
///
/// The metric's birth-declared name.
/// The metric node's stamped identity, or .
+ /// Whether the metric is an *Array variant; stated as
+ /// = "true" so the commit writes an array tag (#754).
///
/// The address fields, or 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").
///
private static IReadOnlyDictionary? 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;
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagConfigKeys.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagConfigKeys.cs
index dbbb7a57..20f6890c 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagConfigKeys.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagConfigKeys.cs
@@ -46,4 +46,13 @@ public static class MqttTagConfigKeys
/// The Sparkplug metric's stable name — the tag's binding key across rebirths.
public const string MetricName = "metricName";
+
+ ///
+ /// The platform's 1-D array key (the one the address-space composer reads for ValueRank). Not an
+ /// address, but it is the one shape key a browse PRODUCES for this driver: a Sparkplug *Array
+ /// 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 — the same
+ /// produce-here/consume-there drift risk the address keys exist to close.
+ ///
+ public const string IsArray = "isArray";
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinition.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinition.cs
index d855c94e..2424bcde 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinition.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinition.cs
@@ -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. for a Plain-mode tag.
///
+///
+/// Whether the tag is a 1-D array (ValueRank=1): the platform's isArray key, read exactly as
+/// the address-space composer reads it (TagConfigIntent — only the JSON literal
+/// counts), so the driver and the node it feeds can never disagree on shape. Read in Sparkplug mode
+/// only, where it binds a tag to a Sparkplug *Array metric (lmxopcua#754); a scalar tag on an array
+/// metric, or an array tag on a scalar metric, reads BadTypeMismatch. Plain mode never sets it.
+///
+///
+/// The declared element count (arrayLength, honoured only on an array tag and only as a JSON
+/// number fitting , as the composer reads it), or /
+/// 0 for a variable-length array (ArrayDimensions=[0]). A Sparkplug array whose element
+/// count differs from a declared length is refused rather than served under the wrong dimensions.
+///
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);
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinitionFactory.cs
index ca629926..709ddc0d 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinitionFactory.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinitionFactory.cs
@@ -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";
}
+ ///
+ /// Reads the platform array-shape keys with TagConfigIntent.ParseArray's exact semantics: an
+ /// array only when isArray is the JSON literal ; a length only on an array,
+ /// and only when arrayLength is a JSON number fitting . 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.
+ ///
+ /// The TagConfig root object.
+ /// The array bit and the declared length ( when absent or not honoured).
+ 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() ?? ""
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/Sparkplug/SparkplugArrayCodec.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/Sparkplug/SparkplugArrayCodec.cs
new file mode 100644
index 00000000..6b37f06e
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/Sparkplug/SparkplugArrayCodec.cs
@@ -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;
+
+///
+/// Decodes the packed bytes_value of a Sparkplug B *Array metric into its elements
+/// (lmxopcua#754, R06:M-5).
+///
+///
+///
+/// The wire format (Sparkplug B 3.0, "Datatype details"). Every array rides in the metric's
+/// bytes_value, packed little-endian with no header: Int8/UInt8 one byte per
+/// element, Int16/UInt16 two, Int32/UInt32/Float four,
+/// Int64/UInt64/Double eight, DateTime eight (signed epoch milliseconds).
+/// Two types differ: a BooleanArray is a little-endian uint32 element count followed by
+/// the bits packed eight per byte, the FIRST element in the MOST significant bit, the last byte
+/// zero-padded; a StringArray is the UTF-8 strings each terminated by 0x00. The unit
+/// tests pin the spec's own examples, which an independent encoder (SparkplugNet 1.3.10) reproduces
+/// byte for byte.
+///
+///
+/// All-or-nothing. 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).
+///
+///
+/// Elements keep their wire type ( for Int8, for
+/// UInt8, epoch milliseconds for DateTime, …). 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.
+///
+///
+public static class SparkplugArrayCodec
+{
+ /// Decodes one packed array body.
+ /// The metric's birth-declared datatype; must be one of the 13 *Array variants.
+ /// The metric's bytes_value.
+ /// The decoded elements on success; otherwise.
+ /// Why the body was refused, when this returns ; never plant data.
+ /// when the whole body decoded.
+ public static bool TryDecode(TahuDataType dataType, ReadOnlySpan 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(ReadOnlySpan source);
+
+ private static bool Fixed(
+ ReadOnlySpan packed, int width, ReadElement 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 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 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();
+ 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;
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs
index c265dce1..3bad9e9d 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs
@@ -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.
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SparkplugIngestor.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SparkplugIngestor.cs
index 5d63e1b7..5da964db 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SparkplugIngestor.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SparkplugIngestor.cs
@@ -126,6 +126,9 @@ public sealed class SparkplugIngestor
/// The metric's value does not fit the tag's effective data type — including an explicit Sparkplug null.
private const uint StatusBadTypeMismatch = 0x80740000u;
+ /// A Sparkplug *Array body that is not a whole, well-formed vector (lmxopcua#754).
+ private const uint StatusBadDecodingError = 0x80070000u;
+
/// The subscribed reference is not an authored Sparkplug tag.
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);
}
+ /// The outcome of decoding one array metric's packed body, shared by every tag bound to it.
+ /// The decoded elements (wire-typed), or when there are none.
+ /// Whether the metric carried a body that is not a well-formed vector.
+ private readonly record struct DecodedArray(Array? Elements, bool Malformed);
+
+ ///
+ /// Decodes an *Array metric's packed bytes_value 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.
+ ///
+ 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);
+ }
+
+ ///
+ /// 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
+ /// when the metric carried no value at all, exactly as .
+ ///
+ 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);
+ }
+
+ ///
+ /// The CLR element type produces for
+ /// — the element type of the array a tag of that type is served as.
+ ///
+ 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,
+ };
+
+ ///
+ /// The metric's shape (array or scalar) disagrees with the tag's (isArray). Warned once per tag
+ /// with the fix, since this is always an authoring error, never a wire one.
+ ///
+ 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);
+ }
+
///
/// Publishes each flattened member of a structured (Template / DataSet) metric as
/// the sibling metric {parent}/{memberPath}.
@@ -1380,7 +1547,7 @@ public sealed class SparkplugIngestor
///
///
/// Each member is re-entered through as a scalar, 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.
///
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawBrowseCommitMapper.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawBrowseCommitMapper.cs
index 9fb8fdb9..befe79e4 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawBrowseCommitMapper.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawBrowseCommitMapper.cs
@@ -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.";
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowseSessionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowseSessionTests.cs
index fd624dde..38a6e306 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowseSessionTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowseSessionTests.cs
@@ -780,12 +780,27 @@ public sealed class MqttBrowseSessionTests
a.DriverDataType.ShouldBe(nameof(Core.Abstractions.DriverDataType.Int32)); // element type
a.IsArray.ShouldBeTrue();
- // R06:M-5: the ingestor refuses array metrics ("not supported in v1"), so the browser must not
- // offer one as bindable — no stated address means the browse-commit refuses it in words, instead
- // of committing a tag that sits at BadWaitingForInitialData forever. The scalar control is
- // SparkplugAttributes_StateTheBindingTuple_SoTheCommitNeverParsesTheNodeId.
- a.AddressFields.ShouldBeNull();
- a.SecurityClass.ShouldContain("not bindable");
+ // #754: the ingestor now reads array metrics as 1-D arrays, so the leaf is bindable again (R06:M-5
+ // made it unbindable while it was not) — but ONLY to an array tag, so the stated address carries the
+ // array bit and the commit writes "isArray": true. Without it the committed tag would be scalar and
+ // read BadTypeMismatch forever, which is R06:M-5 by another route.
+ a.SecurityClass.ShouldNotContain("not bindable");
+ var fields = a.AddressFields.ShouldNotBeNull();
+ fields[MqttTagConfigKeys.MetricName].ShouldBe("Profile");
+ fields[MqttTagConfigKeys.IsArray].ShouldBe("true");
+ }
+
+ /// Control: a SCALAR metric's address never states the array bit (absence is the scalar default).
+ [Fact]
+ public async Task SparkplugAttributes_ScalarMetric_DoesNotStateIsArray()
+ {
+ await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
+ s.ObserveBirthForTest("Plant1", "EdgeA", null, "Speed", SparkplugDataType.Int32);
+
+ var a = (await s.AttributesAsync(
+ "Plant1/EdgeA::Speed", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
+ a.IsArray.ShouldBeFalse();
+ a.AddressFields.ShouldNotBeNull().ContainsKey(MqttTagConfigKeys.IsArray).ShouldBeFalse();
}
[Fact]
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverDiscoveryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverDiscoveryTests.cs
index 22ca34f0..e48b4667 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverDiscoveryTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverDiscoveryTests.cs
@@ -540,6 +540,36 @@ public sealed class MqttDriverDiscoveryTests
b.Variables[0].Info.DriverDataType.ShouldBe(DriverDataType.Int32);
}
+ ///
+ /// #754: an array tag is discovered as what the composer materialises — the birth's ELEMENT type, the
+ /// array bit, and the declared length as ArrayDim (null = variable length). A scalar tag stays
+ /// scalar even when the metric is an array (the ingest refuses that shape; discovery must not hide it).
+ ///
+ [Fact]
+ public async Task DiscoverAsync_Sparkplug_ArrayTag_ReportsElementType_IsArray_AndDeclaredLength()
+ {
+ const string fixedPath = "Plant/Mqtt/spb/Fixed";
+ const string openPath = "Plant/Mqtt/spb/Open";
+ var fixedBlob = SpBlob(SpDevice, "Profile").TrimEnd('}') + ""","isArray":true,"arrayLength":4}""";
+ var openBlob = SpBlob(SpDevice, "Profile").TrimEnd('}') + ""","isArray":true}""";
+ var driver = SparkplugDriver(
+ SpTag(fixedPath, fixedBlob), SpTag(openPath, openBlob), SpTag(SpTempPath, SpBlob(SpDevice, "Profile")));
+ FeedNodeBirth(driver);
+ FeedDeviceBirth(driver, seq: 1, ("Profile", 5UL, TahuDataType.Int32Array));
+
+ var b = new CapturingAddressSpaceBuilder();
+ await driver.DiscoverAsync(b, TestContext.Current.CancellationToken);
+
+ var byName = b.Variables.ToDictionary(v => v.BrowseName, v => v.Info);
+ byName[fixedPath].DriverDataType.ShouldBe(DriverDataType.Int32);
+ byName[fixedPath].IsArray.ShouldBeTrue();
+ byName[fixedPath].ArrayDim.ShouldBe(4u);
+ byName[openPath].IsArray.ShouldBeTrue();
+ byName[openPath].ArrayDim.ShouldBeNull();
+ byName[SpTempPath].IsArray.ShouldBeFalse();
+ byName[SpTempPath].ArrayDim.ShouldBeNull();
+ }
+
///
/// A birth declaring a Sparkplug type this driver cannot map (DataSet / Template / PropertySet
/// / Unknown) must not blank the tag's type — ToDriverDataType() returns null for those,
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugArrayCodecTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugArrayCodecTests.cs
new file mode 100644
index 00000000..e20d88f4
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugArrayCodecTests.cs
@@ -0,0 +1,126 @@
+using System.Buffers.Binary;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
+using TahuDataType = Org.Eclipse.Tahu.Protobuf.DataType;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
+
+///
+/// Covers (lmxopcua#754). The golden vectors are the Sparkplug B 3.0
+/// specification's own "Datatype details" examples, and an independent encoder (SparkplugNet 1.3.10,
+/// PayloadConverter.ConvertVersionBMetric) was run against the same inputs and produced the same bytes
+/// — so the bit order of a BooleanArray and the string terminator are not this repo agreeing with itself.
+///
+public sealed class SparkplugArrayCodecTests
+{
+ /// Decodes and pins the WIRE element type the codec hands the ingestor.
+ private static T[] Decode(TahuDataType type, params byte[] packed)
+ {
+ SparkplugArrayCodec.TryDecode(type, packed, out var elements, out var error).ShouldBeTrue(error);
+ return elements.ShouldBeOfType();
+ }
+
+ private static string Refuse(TahuDataType type, params byte[] packed)
+ {
+ SparkplugArrayCodec.TryDecode(type, packed, out var elements, out var error).ShouldBeFalse();
+ elements.ShouldBeNull();
+ return error.ShouldNotBeNull();
+ }
+
+ // ---- golden vectors (spec examples; SparkplugNet reproduces them byte for byte) ----
+
+ /// uint32 LE count, then the bits MSB-first: 0x34 = F,F,T,T,F,T,F,F and 0xD0 = T,T,F,T + padding.
+ [Fact]
+ public void BooleanArray_IsCountPrefixed_AndPackedMostSignificantBitFirst()
+ => Decode(TahuDataType.BooleanArray, 0x0C, 0x00, 0x00, 0x00, 0x34, 0xD0)
+ .ShouldBe(new[] { false, false, true, true, false, true, false, false, true, true, false, true });
+
+ [Fact]
+ public void Int8Array_IsOneSignedBytePerElement()
+ => Decode(TahuDataType.Int8Array, 0xE9, 0x7B).ShouldBe(new sbyte[] { -23, 123 });
+
+ [Fact]
+ public void Int16Array_IsLittleEndian()
+ => Decode(TahuDataType.Int16Array, 0xD0, 0x8A, 0x30, 0x75).ShouldBe(new short[] { -30000, 30000 });
+
+ [Fact]
+ public void Uint32Array_IsLittleEndian()
+ => Decode(TahuDataType.Uint32Array, 0x34, 0x00, 0x00, 0x00, 0x49, 0xFB, 0x55, 0xC4)
+ .ShouldBe(new uint[] { 52, 3293969225 });
+
+ [Fact]
+ public void FloatArray_IsLittleEndianIeee754()
+ => Decode(TahuDataType.FloatArray, 0xA4, 0x70, 0x9D, 0x3F, 0x98, 0xAE, 0xB2, 0x42)
+ .ShouldBe(new[] { 1.23f, 89.341f });
+
+ [Fact]
+ public void StringArray_IsNullTerminatedUtf8()
+ => Decode(TahuDataType.StringArray, 0x41, 0x42, 0x43, 0x00, 0x68, 0x65, 0x6C, 0x6C, 0x6F, 0x00)
+ .ShouldBe(new[] { "ABC", "hello" });
+
+ /// Signed int64 epoch milliseconds — left as the wire long; the ingestor coerces it to a DateTime.
+ [Fact]
+ public void DateTimeArray_IsInt64EpochMilliseconds()
+ => Decode(TahuDataType.DateTimeArray, 0xC7, 0xD0, 0x90, 0x75, 0x24, 0x01, 0x00, 0x00)
+ .ShouldBe(new[] { 1256102875335L });
+
+ // ---- the remaining widths, encoded with the BCL so the decoder is not checked against itself ----
+
+ [Fact]
+ public void RemainingWidths_RoundTripTheBclLittleEndianEncoding()
+ {
+ var bytes = new byte[16];
+ BinaryPrimitives.WriteInt64LittleEndian(bytes, -5L);
+ BinaryPrimitives.WriteInt64LittleEndian(bytes.AsSpan(8), long.MaxValue);
+ Decode(TahuDataType.Int64Array, bytes).ShouldBe(new[] { -5L, long.MaxValue });
+
+ BinaryPrimitives.WriteUInt64LittleEndian(bytes, ulong.MaxValue);
+ BinaryPrimitives.WriteUInt64LittleEndian(bytes.AsSpan(8), 7UL);
+ Decode(TahuDataType.Uint64Array, bytes).ShouldBe(new[] { ulong.MaxValue, 7UL });
+
+ BinaryPrimitives.WriteDoubleLittleEndian(bytes, -1.5);
+ BinaryPrimitives.WriteDoubleLittleEndian(bytes.AsSpan(8), 1e300);
+ Decode(TahuDataType.DoubleArray, bytes).ShouldBe(new[] { -1.5, 1e300 });
+
+ Decode(TahuDataType.Uint8Array, 0x00, 0xFF).ShouldBe(new byte[] { 0, 255 });
+ Decode(TahuDataType.Uint16Array, 0xFF, 0xFF, 0x01, 0x00).ShouldBe(new ushort[] { 65535, 1 });
+ Decode(TahuDataType.Int32Array, 0xD6, 0xFF, 0xFF, 0xFF).ShouldBe(new[] { -42 });
+ }
+
+ [Fact]
+ public void EmptyBodies_AreEmptyArrays_ExceptBooleanWhichNeedsItsCount()
+ {
+ Decode(TahuDataType.Int32Array).Length.ShouldBe(0);
+ Decode(TahuDataType.StringArray).Length.ShouldBe(0);
+ Decode(TahuDataType.BooleanArray, 0x00, 0x00, 0x00, 0x00).Length.ShouldBe(0);
+ Refuse(TahuDataType.BooleanArray);
+ }
+
+ // ---- malformed bodies: all-or-nothing, never a partial vector ----
+
+ [Fact]
+ public void ABodyThatIsNotAWholeNumberOfElements_IsRefused()
+ => Refuse(TahuDataType.Int32Array, 1, 2, 3, 4, 5).ShouldContain("whole number");
+
+ [Fact]
+ public void ABooleanCountTheBodyCannotHold_IsRefused()
+ => Refuse(TahuDataType.BooleanArray, 0x0C, 0x00, 0x00, 0x00, 0x34).ShouldContain("needs 2");
+
+ /// Exact, not "at least": a trailing byte means the count and the body disagree.
+ [Fact]
+ public void ABooleanBodyLongerThanItsCountNeeds_IsRefused()
+ => Refuse(TahuDataType.BooleanArray, 0x08, 0x00, 0x00, 0x00, 0xFF, 0x00).ShouldContain("needs 1");
+
+ [Fact]
+ public void AnUnterminatedLastString_IsRefused()
+ => Refuse(TahuDataType.StringArray, 0x41, 0x00, 0x42).ShouldContain("terminated");
+
+ [Fact]
+ public void InvalidUtf8_IsRefused_NotReplacementCharactered()
+ => Refuse(TahuDataType.StringArray, 0xC0, 0xC0, 0x00).ShouldContain("UTF-8");
+
+ [Fact]
+ public void AScalarDatatype_IsNotAnArray()
+ => Refuse(TahuDataType.Int32, 0, 0, 0, 0).ShouldContain("not a Sparkplug array");
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugArrayIngestTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugArrayIngestTests.cs
new file mode 100644
index 00000000..233ff7d4
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugArrayIngestTests.cs
@@ -0,0 +1,327 @@
+using System.Buffers.Binary;
+using Google.Protobuf;
+using Org.Eclipse.Tahu.Protobuf;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
+using TahuDataType = Org.Eclipse.Tahu.Protobuf.DataType;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
+
+///
+/// lmxopcua#754 (R06:M-5): Sparkplug B *Array metrics are read into 1-D OPC UA array values. Every
+/// test builds real Tahu protobuf payloads, encodes them to wire bytes, decodes them through the real
+/// and feeds — the method MQTTnet's
+/// dispatcher thread reaches — so this is the driver's production ingest path end to end, short of a broker.
+/// (The project's MiniBroker never forwards a PUBLISH, so there is no in-process broker to route
+/// through; the live Mosquitto + simulator suite is Driver.Mqtt.IntegrationTests.)
+///
+public sealed class SparkplugArrayIngestTests
+{
+ private const uint Good = 0x00000000u;
+ private const uint BadDecodingError = 0x80070000u;
+ private const uint BadTypeMismatch = 0x80740000u;
+
+ private const string Group = "Plant1";
+ private const string Node = "EdgeA";
+ private const string Device = "Filler1";
+ private const string ArrPath = "Plant/Mqtt/spb/Profile";
+ private const ulong ArrAlias = 11UL;
+
+ // ---- the value: a 1-D array of the tag's element type ----
+
+ /// An unauthored dataType takes the birth's ELEMENT type, and the value is an int[] — not a byte[].
+ [Fact]
+ public async Task Int32Array_OnAnArrayTag_PublishesAnInt32Array_AtGood()
+ {
+ var (ing, seen) = await SubscribedAsync(ArrayBlob());
+
+ Birth(ing, TahuDataType.Int32Array, Int32s(-42, 0, 70000));
+
+ var snapshot = seen.ShouldHaveSingleItem();
+ snapshot.StatusCode.ShouldBe(Good);
+ snapshot.Value.ShouldBeOfType().ShouldBe(new[] { -42, 0, 70000 });
+ }
+
+ /// The DATA path (alias only, no datatype on the wire) decodes by the birth's binding, like a scalar.
+ [Fact]
+ public async Task Ddata_ByAlias_PublishesTheNewVector()
+ {
+ var (ing, seen) = await SubscribedAsync(ArrayBlob());
+ Birth(ing, TahuDataType.Int32Array, Int32s(1, 2));
+
+ var data = new Payload { Seq = 2UL, Timestamp = 1721822405000UL };
+ data.Metrics.Add(new Payload.Types.Metric { Alias = ArrAlias, BytesValue = ByteString.CopyFrom(Int32s(3, 4, 5)) });
+ ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Decode(data));
+
+ seen.Count.ShouldBe(2);
+ seen[1].StatusCode.ShouldBe(Good);
+ seen[1].Value.ShouldBe(new[] { 3, 4, 5 });
+ ing.Values.Read(ArrPath).Value.ShouldBe(new[] { 3, 4, 5 });
+ }
+
+ /// An authored element type wins over the birth's, element by element through the scalar coercion.
+ [Fact]
+ public async Task AnAuthoredElementType_Wins_OverTheBirth()
+ {
+ var (ing, seen) = await SubscribedAsync(ArrayBlob(dataType: "Float64"));
+
+ Birth(ing, TahuDataType.Int32Array, Int32s(-1, 2));
+
+ seen.ShouldHaveSingleItem().Value.ShouldBeOfType().ShouldBe(new[] { -1d, 2d });
+ }
+
+ /// Int8 widens to Int16, exactly as the scalar map does (no OPC UA SByte in DriverDataType).
+ [Fact]
+ public async Task Int8Array_WidensToAnInt16Array()
+ {
+ var (ing, seen) = await SubscribedAsync(ArrayBlob());
+
+ Birth(ing, TahuDataType.Int8Array, [0xE9, 0x7B]);
+
+ seen.ShouldHaveSingleItem().Value.ShouldBeOfType().ShouldBe(new short[] { -23, 123 });
+ }
+
+ [Fact]
+ public async Task BooleanArray_PublishesABoolArray()
+ {
+ var (ing, seen) = await SubscribedAsync(ArrayBlob());
+
+ Birth(ing, TahuDataType.BooleanArray, [0x03, 0x00, 0x00, 0x00, 0xA0]);
+
+ seen.ShouldHaveSingleItem().Value.ShouldBeOfType().ShouldBe(new[] { true, false, true });
+ }
+
+ [Fact]
+ public async Task StringArray_PublishesAStringArray()
+ {
+ var (ing, seen) = await SubscribedAsync(ArrayBlob());
+
+ Birth(ing, TahuDataType.StringArray, [0x41, 0x42, 0x43, 0x00, 0x00, 0x7A, 0x00]);
+
+ seen.ShouldHaveSingleItem().Value.ShouldBeOfType().ShouldBe(new[] { "ABC", "", "z" });
+ }
+
+ [Fact]
+ public async Task DateTimeArray_PublishesUtcDateTimes()
+ {
+ var (ing, seen) = await SubscribedAsync(ArrayBlob());
+ var bytes = new byte[8];
+ BinaryPrimitives.WriteInt64LittleEndian(bytes, 1256102875335L);
+
+ Birth(ing, TahuDataType.DateTimeArray, bytes);
+
+ var value = seen.ShouldHaveSingleItem().Value.ShouldBeOfType().ShouldHaveSingleItem();
+ value.ShouldBe(DateTimeOffset.FromUnixTimeMilliseconds(1256102875335L).UtcDateTime);
+ value.Kind.ShouldBe(DateTimeKind.Utc);
+ }
+
+ // ---- a declared arrayLength is the node's ArrayDimensions ----
+
+ [Fact]
+ public async Task AMatchingDeclaredLength_IsServed()
+ {
+ var (ing, seen) = await SubscribedAsync(ArrayBlob(arrayLength: 3));
+
+ Birth(ing, TahuDataType.Int32Array, Int32s(1, 2, 3));
+
+ seen.ShouldHaveSingleItem().StatusCode.ShouldBe(Good);
+ }
+
+ /// A vector that is not the node's declared length is not a value of that node (MTConnect's #627 verdict).
+ [Fact]
+ public async Task AVectorOfAnotherLength_IsBadDecodingError_NotServedUnderTheWrongDimensions()
+ {
+ var (ing, seen) = await SubscribedAsync(ArrayBlob(arrayLength: 4));
+
+ Birth(ing, TahuDataType.Int32Array, Int32s(1, 2, 3));
+
+ var snapshot = seen.ShouldHaveSingleItem();
+ snapshot.StatusCode.ShouldBe(BadDecodingError);
+ snapshot.Value.ShouldBeNull();
+ }
+
+ // ---- refusals: all-or-nothing, and never the packed buffer ----
+
+ [Fact]
+ public async Task AMalformedBody_IsBadDecodingError()
+ {
+ var (ing, seen) = await SubscribedAsync(ArrayBlob());
+
+ Birth(ing, TahuDataType.Int32Array, [1, 2, 3, 4, 5]);
+
+ var snapshot = seen.ShouldHaveSingleItem();
+ snapshot.StatusCode.ShouldBe(BadDecodingError);
+ snapshot.Value.ShouldBeNull();
+ }
+
+ /// One element that does not fit the effective type refuses the WHOLE vector — never a partial array.
+ [Fact]
+ public async Task OneElementThatDoesNotFit_RefusesTheWholeVector()
+ {
+ var (ing, seen) = await SubscribedAsync(ArrayBlob(dataType: "Int16"));
+
+ Birth(ing, TahuDataType.Int32Array, Int32s(1, 70000));
+
+ var snapshot = seen.ShouldHaveSingleItem();
+ snapshot.StatusCode.ShouldBe(BadTypeMismatch);
+ snapshot.Value.ShouldBeNull();
+ }
+
+ /// A SCALAR metric on an array tag: the shapes disagree, refused rather than wrapped in a one-element array.
+ [Fact]
+ public async Task AScalarMetric_OnAnArrayTag_IsBadTypeMismatch()
+ {
+ var (ing, seen) = await SubscribedAsync(ArrayBlob());
+ Nbirth(ing);
+
+ var birth = new Payload { Seq = 1UL, Timestamp = 1UL };
+ birth.Metrics.Add(new Payload.Types.Metric
+ {
+ Name = "Profile", Alias = ArrAlias, Datatype = (uint)TahuDataType.Int32, IntValue = 5,
+ });
+ ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Decode(birth));
+
+ var snapshot = seen.ShouldHaveSingleItem();
+ snapshot.StatusCode.ShouldBe(BadTypeMismatch);
+ snapshot.Value.ShouldBeNull();
+ }
+
+ /// A publisher that puts a scalar oneof on an array-typed metric: there is no packed body to decode.
+ [Fact]
+ public async Task ANonBytesValue_OnAnArrayMetric_IsBadTypeMismatch()
+ {
+ var (ing, seen) = await SubscribedAsync(ArrayBlob());
+ Nbirth(ing);
+
+ var birth = new Payload { Seq = 1UL, Timestamp = 1UL };
+ birth.Metrics.Add(new Payload.Types.Metric
+ {
+ Name = "Profile", Alias = ArrAlias, Datatype = (uint)TahuDataType.Int32Array, IntValue = 5,
+ });
+ ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Decode(birth));
+
+ seen.ShouldHaveSingleItem().StatusCode.ShouldBe(BadTypeMismatch);
+ }
+
+ [Fact]
+ public async Task AnExplicitNull_OnAnArrayMetric_IsBadTypeMismatch()
+ {
+ var (ing, seen) = await SubscribedAsync(ArrayBlob());
+ Nbirth(ing);
+
+ var birth = new Payload { Seq = 1UL, Timestamp = 1UL };
+ birth.Metrics.Add(new Payload.Types.Metric
+ {
+ Name = "Profile", Alias = ArrAlias, Datatype = (uint)TahuDataType.Int32Array, IsNull = true,
+ });
+ ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Decode(birth));
+
+ seen.ShouldHaveSingleItem().StatusCode.ShouldBe(BadTypeMismatch);
+ }
+
+ ///
+ /// A template member that is itself an array re-enters the same publish funnel, so it is decoded too —
+ /// the structured path cannot silently lag the top-level one.
+ ///
+ [Fact]
+ public async Task AnArrayTemplateMember_IsDecodedLikeATopLevelArray()
+ {
+ const string MemberPath = "Plant/Mqtt/spb/MotorProfile";
+ var blob = $$"""{"groupId":"{{Group}}","edgeNodeId":"{{Node}}","deviceId":"{{Device}}","metricName":"Motor/Profile","isArray":true}""";
+ var ing = NewIngestor([new RawTagEntry(MemberPath, blob, WriteIdempotent: false)]);
+ await ing.SubscribeAsync([MemberPath], TimeSpan.Zero, TestContext.Current.CancellationToken);
+ var seen = new List();
+ ing.OnDataChange += (_, e) => seen.Add(e.Snapshot);
+ Nbirth(ing);
+
+ var template = new Payload.Types.Template { IsDefinition = false };
+ template.Metrics.Add(new Payload.Types.Metric
+ {
+ Name = "Profile", Datatype = (uint)TahuDataType.Int16Array, BytesValue = ByteString.CopyFrom(0x01, 0x00, 0xFF, 0xFF),
+ });
+ var birth = new Payload { Seq = 1UL, Timestamp = 1UL };
+ birth.Metrics.Add(new Payload.Types.Metric
+ {
+ Name = "Motor", Alias = 30UL, Datatype = (uint)TahuDataType.Template, TemplateValue = template,
+ });
+ ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Decode(birth));
+
+ var snapshot = seen.ShouldHaveSingleItem();
+ snapshot.StatusCode.ShouldBe(Good);
+ snapshot.Value.ShouldBeOfType().ShouldBe(new short[] { 1, -1 });
+ }
+
+ // ---- helpers ----
+
+ private static string ArrayBlob(string? dataType = null, uint? arrayLength = null)
+ {
+ var typeKey = dataType is null ? "" : $""","dataType":"{dataType}" """;
+ var lengthKey = arrayLength is null ? "" : $""","arrayLength":{arrayLength}""";
+ return $$"""{"groupId":"{{Group}}","edgeNodeId":"{{Node}}","deviceId":"{{Device}}","metricName":"Profile","isArray":true{{typeKey}}{{lengthKey}}}""";
+ }
+
+ private static async Task<(SparkplugIngestor Ingestor, List Seen)> SubscribedAsync(string blob)
+ {
+ var ing = NewIngestor([new RawTagEntry(ArrPath, blob, WriteIdempotent: false)]);
+ await ing.SubscribeAsync([ArrPath], TimeSpan.Zero, TestContext.Current.CancellationToken);
+ var seen = new List();
+ ing.OnDataChange += (_, e) => seen.Add(e.Snapshot);
+ return (ing, seen);
+ }
+
+ private static SparkplugIngestor NewIngestor(RawTagEntry[] tags)
+ {
+ var ingestor = new SparkplugIngestor(
+ new MqttDriverOptions
+ {
+ Mode = MqttMode.SparkplugB,
+ Sparkplug = new MqttSparkplugOptions { GroupId = Group, HostId = "otopcua-host-1" },
+ },
+ "mqtt-spb-1",
+ null,
+ null,
+ logger: null,
+ cache: null,
+ maxPayloadBytes: MqttSubscriptionManager.DefaultMaxPayloadBytes,
+ rebirthDebounce: TimeSpan.Zero);
+ ingestor.Register(tags);
+ return ingestor;
+ }
+
+ private static void Nbirth(SparkplugIngestor ing)
+ {
+ var payload = new Payload { Seq = 0UL, Timestamp = 1721822400000UL };
+ payload.Metrics.Add(new Payload.Types.Metric { Name = "bdSeq", Datatype = (uint)TahuDataType.Uint64, LongValue = 1UL });
+ ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Decode(payload));
+ }
+
+ /// NBIRTH, then a DBIRTH declaring Profile as carrying .
+ private static void Birth(SparkplugIngestor ing, TahuDataType type, byte[] packed)
+ {
+ Nbirth(ing);
+ var birth = new Payload { Seq = 1UL, Timestamp = 1721822400000UL };
+ birth.Metrics.Add(new Payload.Types.Metric
+ {
+ Name = "Profile", Alias = ArrAlias, Datatype = (uint)type, BytesValue = ByteString.CopyFrom(packed),
+ });
+ ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Decode(birth));
+ }
+
+ private static byte[] Int32s(params int[] values)
+ {
+ var bytes = new byte[values.Length * 4];
+ for (var i = 0; i < values.Length; i++)
+ {
+ BinaryPrimitives.WriteInt32LittleEndian(bytes.AsSpan(i * 4), values[i]);
+ }
+
+ return bytes;
+ }
+
+ private static SparkplugTopic Topic(SparkplugMessageType type, string? device = null) => new(type, Group, Node, device, null);
+
+ /// Through the wire: encode the generated message, then decode it with the production codec.
+ private static SparkplugPayload Decode(Payload payload) => SparkplugCodec.Decode(payload.ToByteArray());
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugIngestorTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugIngestorTests.cs
index 43c44103..259554b9 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugIngestorTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugIngestorTests.cs
@@ -1344,10 +1344,13 @@ public sealed class SparkplugIngestorTests
///
/// Array metrics slip past the unsupported-type gate, because ToDriverDataType maps every
/// *Array variant to its ELEMENT type and is therefore never null for one. The value
- /// rides as a packed byte[], so a String-typed tag would publish base64 at GOOD quality.
+ /// rides as a packed byte[], so a String-typed SCALAR tag would publish base64 at GOOD quality.
+ /// Since #754 an array metric is read — but only onto an array tag; a scalar tag on one is a shape
+ /// mismatch, BadTypeMismatch, never the packed buffer. The array tag itself is covered by
+ /// .
///
[Fact]
- public async Task ArrayMetric_IsSkipped_NotPublishedAsBase64()
+ public async Task ArrayMetric_OnAScalarTag_IsBadTypeMismatch_NotPublishedAsBase64()
{
const string ArrTag = "Plant/Mqtt/spb/TempArray";
var ing = NewIngestor(tags: [Tag(ArrTag, Blob(Group, Node, Device, "Temperature", "String"))]);
@@ -1368,8 +1371,10 @@ public sealed class SparkplugIngestorTests
});
ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Decode(birth));
- seen.ShouldBeEmpty();
- ing.Values.Read(ArrTag).StatusCode.ShouldNotBe(Good);
+ var snapshot = seen.ShouldHaveSingleItem();
+ snapshot.StatusCode.ShouldBe(BadTypeMismatch);
+ snapshot.Value.ShouldBeNull();
+ ing.Values.Read(ArrTag).StatusCode.ShouldBe(BadTypeMismatch);
}
///
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MqttArrayShapeParityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MqttArrayShapeParityTests.cs
new file mode 100644
index 00000000..3152791c
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MqttArrayShapeParityTests.cs
@@ -0,0 +1,75 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
+using ZB.MOM.WW.OtOpcUa.Commons.Types;
+using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
+
+///
+/// lmxopcua#754: a Sparkplug tag's array shape is read by THREE parties — the address-space composer
+/// (, which decides ValueRank/ArrayDimensions), the driver
+/// (, which decides whether an array metric feeds
+/// the tag) and the authoring gate ( / ,
+/// which consult the driver's own parse). The driver's copy of the read lives in a Contracts assembly that cannot
+/// reference Commons, so this pins it against the composer's over a corpus of edge shapes, and pins that
+/// the array keys never change what authoring accepts.
+///
+[Trait("Category", "Unit")]
+public sealed class MqttArrayShapeParityTests
+{
+ private const string Tuple = """ "groupId":"Plant1","edgeNodeId":"EdgeA","deviceId":"Filler1","metricName":"Profile" """;
+
+ private static string B(string extraKeys) => "{" + Tuple + extraKeys + "}";
+
+ public static TheoryData Corpus() => new()
+ {
+ B(""),
+ B(""","isArray":true"""),
+ B(""","isArray":false"""),
+ B(""""
+ ,"isArray":"true"
+ """"), // a string is NOT an array to the composer
+ B(""","isArray":1"""),
+ B(""","isArray":true,"arrayLength":8"""),
+ B(""","isArray":true,"arrayLength":0"""),
+ B(""","isArray":true,"arrayLength":-1"""),
+ B(""","isArray":true,"arrayLength":4294967296"""),
+ B(""","isArray":true,"arrayLength":2.5"""),
+ B(""""
+ ,"isArray":true,"arrayLength":"8"
+ """"),
+ B(""","arrayLength":8"""), // a length without the bit is ignored
+ B(""","isArray":true,"dataType":"Float64","arrayLength":3"""),
+ };
+
+ /// The driver reads the shape exactly as the composer materialises the node.
+ [Theory]
+ [MemberData(nameof(Corpus))]
+ public void The_driver_reads_the_array_shape_exactly_as_the_composer_does(string blob)
+ {
+ var intent = TagConfigIntent.Parse(blob);
+
+ MqttTagDefinitionFactory.FromSparkplugTagConfig(blob, "Plant/Mqtt/Profile", out var def).ShouldBeTrue(blob);
+
+ def.IsArray.ShouldBe(intent.IsArray);
+ def.ArrayLength.ShouldBe(intent.ArrayLength);
+ }
+
+ ///
+ /// The array keys are never a rejection cause: the node exists whatever they say, so a driver that
+ /// refused the tag would serve BadNodeIdUnknown on a live node. Authoring therefore accepts every one
+ /// of these, because the driver does — the model and the deploy-time inspector both consult its parse.
+ ///
+ [Theory]
+ [MemberData(nameof(Corpus))]
+ public void Authoring_accepts_exactly_what_the_driver_serves(string blob)
+ {
+ MqttTagDefinitionFactory.FromSparkplugTagConfig(blob, "Plant/Mqtt/Profile", out _).ShouldBeTrue();
+
+ MqttTagConfigModel.FromJson(blob).Validate().ShouldBeNull();
+ EquipmentTagConfigInspector.Inspect(DriverTypeNames.Mqtt, blob).ShouldBeEmpty();
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MqttUnbindableLeafMessageTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MqttUnbindableLeafMessageTests.cs
index 77c6d3c8..785b3921 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MqttUnbindableLeafMessageTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MqttUnbindableLeafMessageTests.cs
@@ -13,9 +13,10 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
public sealed class MqttUnbindableLeafMessageTests
{
[Fact]
- public void An_array_metric_says_it_is_not_bindable_and_echoes_the_descriptor()
+ public void An_unsupported_metric_says_it_is_not_bindable_and_echoes_the_descriptor()
{
- const string descriptor = "Metric · Int32Array · alias 7 · array metrics are not read in v1 — not bindable";
+ // (Array metrics were the original example; they are bindable since #754 — an unsupported type is not.)
+ const string descriptor = "Metric · DataSet · alias 7 · unsupported in v1 — not bindable";
var error = RawBrowseCommitMapper.DescribeUncommittableLeaf("Mqtt", "Pressures", addressFields: null, descriptor);
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawBrowseCommitMapperTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawBrowseCommitMapperTests.cs
index bb70c77c..3a9ab8e4 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawBrowseCommitMapperTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawBrowseCommitMapperTests.cs
@@ -402,6 +402,42 @@ public sealed class RawBrowseCommitMapperTests
o.ContainsKey("address").ShouldBeFalse(); // and never the generic fallback key
}
+ ///
+ /// #754: a Sparkplug *Array metric binds only to an ARRAY tag, so the commit writes the array bit the
+ /// session stated — as the JSON literal true, the only form both the composer (TagConfigIntent,
+ /// ValueRank=1) and the driver read as an array — and no arrayLength (variable length). The committed
+ /// blob is then checked against BOTH readers so authoring cannot drift from what is served.
+ ///
+ [Fact]
+ public void BuildTagConfig_Mqtt_sparkplug_array_metric_writes_isArray_true_that_both_readers_take_as_an_array()
+ {
+ var fields = SparkplugFields("Profile");
+ fields[MqttTagConfigKeys.IsArray] = "true";
+
+ var json = RawBrowseCommitMapper.BuildTagConfig("Mqtt", "OtOpcUaSim/EdgeA/Filler1::Profile", fields);
+
+ var o = JsonNode.Parse(json)!.AsObject();
+ o["isArray"]!.GetValueKind().ShouldBe(JsonValueKind.True);
+ o.ContainsKey("arrayLength").ShouldBeFalse();
+
+ var intent = TagConfigIntent.Parse(json);
+ intent.IsArray.ShouldBeTrue();
+ intent.ArrayLength.ShouldBeNull();
+ MqttTagDefinitionFactory.FromSparkplugTagConfig(json, "Plant/Mqtt/Profile", out var def).ShouldBeTrue();
+ def.IsArray.ShouldBeTrue();
+ def.ArrayLength.ShouldBeNull();
+ }
+
+ /// Control: a scalar metric's commit carries no array bit at all.
+ [Fact]
+ public void BuildTagConfig_Mqtt_sparkplug_scalar_metric_writes_no_isArray()
+ {
+ var json = RawBrowseCommitMapper.BuildTagConfig("Mqtt", "OtOpcUaSim/EdgeA/Filler1::Temperature", SparkplugFields());
+
+ JsonNode.Parse(json)!.AsObject().ContainsKey("isArray").ShouldBeFalse();
+ TagConfigIntent.Parse(json).IsArray.ShouldBeFalse();
+ }
+
[Fact]
public void BuildTagConfig_Mqtt_sparkplug_node_level_metric_omits_deviceId_entirely()
{