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() {