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:
@@ -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