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