fix(authoring): CSV import accepts variable-length arrays; Plain MQTT isArray warns at authoring (lmxopcua#770)

CSV: IsArray with blank/0 ArrayLength is the variable-length shape browse-commit and the composer produce, so its own export now re-imports.
Plain MQTT: MqttTagDefinitionFactory.Inspect (the driver's parse, consulted by the authoring inspector) now advises when isArray is set on a Plain-shaped tag; Sparkplug-shaped blobs stay quiet.
Live Sparkplug array test and SDK reflection items remain open.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 14:16:29 -05:00
parent 3d26907b1a
commit b8d2fc37ce
4 changed files with 43 additions and 5 deletions
@@ -431,6 +431,22 @@ public static class MqttTagDefinitionFactory
+ "never bind and will read BadNodeIdUnknown in Sparkplug B mode.");
}
// isArray on a PLAIN-shaped tag (lmxopcua#770). The composer materialises the node from isArray
// (ValueRank=1) in every mode, but only the Sparkplug ingest path ever feeds it an array: a Plain
// tag decodes ONE scalar per message, so the node would be an array fed scalar values. Warned only
// when the Plain reader takes the blob and the Sparkplug one does not (a Sparkplug blob carrying a
// leftover topic key is a legitimate array tag and must stay quiet — a gate stricter than the runtime).
if (accepted && ReadArrayShape(root).IsArray
&& FromTagConfig(root.GetRawText(), InspectionRawPath, out _)
&& !FromSparkplugTagConfig(root.GetRawText(), InspectionRawPath, out _))
{
advisories.Add(
"'isArray' is set on a Plain-mode tag: Plain MQTT decodes one scalar per message and never "
+ "produces an array, so the tag would be an array node fed scalar values. 'isArray' is "
+ "honoured only in Sparkplug B mode (array metrics) — remove it, or switch the driver to "
+ "Sparkplug B.");
}
// The ONE key whose verdict depends on the DRIVER'S mode, reported as an
// advisory because no tag-level gate can see that mode. A present-but-invalid payloadFormat is
// fatal to the Plain parse and invisible to the Sparkplug one, so a union accept here can only
@@ -225,11 +225,9 @@ public static class RawTagCsvMapper
arrayLength = (uint)n;
}
if (TagArrayConfig.Validate(isArray, arrayLength) is { } arrErr)
{
return Invalid(rowNumber, cells, arrErr);
}
// No positive-length requirement (lmxopcua#770): IsArray with a blank/0 ArrayLength is a
// variable-length array (ArrayDimensions=[0]), which browse-commit and the composer both produce and
// honour, so the export of such a tag must re-import. TagArrayConfig.Validate stays the editor's rule.
json = TagArrayConfig.Set(json, isArray, arrayLength);
// Alarm flags — an alarm is authored iff Alarm.AlarmType is present.
@@ -72,6 +72,18 @@ public sealed class MqttTagDefinitionFactoryTests
=> MqttTagDefinitionFactory.FromTagConfig(
"""{"topic":"a/b","payloadFormat":"Json","dataType":"Double"}""", RawPath, out _).ShouldBeFalse();
[Fact]
public void Inspect_IsArrayOnPlainTag_Warns_lmxopcua_770()
{
var warnings = MqttTagDefinitionFactory.Inspect("""{"topic":"a/b","payloadFormat":"Json","isArray":true}""");
warnings.ShouldHaveSingleItem().ShouldContain("isArray");
}
[Fact]
public void Inspect_IsArrayOnSparkplugTag_StaysQuiet()
=> MqttTagDefinitionFactory.Inspect(
"""{"groupId":"G","edgeNodeId":"E","metricName":"M","topic":"leftover","isArray":true}""").ShouldBeEmpty();
[Fact]
public void Inspect_WildcardTopic_ReturnsWarning()
{
@@ -68,6 +68,18 @@ public sealed class RawTagCsvRoundTripTests
AssertRoundTrip(DriverTypeNames.Galaxy, new[] { galaxy });
}
[Fact]
public void Variable_length_array_round_trips_lmxopcua_770()
{
// IsArray with no ArrayLength is what browse-commit and the composer produce (ArrayDimensions=[0]);
// the CSV import used to reject its own export.
var variable = new RawCsvExportTag(
null, "profile", "Float", TagAccessLevel.Read, false, null,
"""{"nodeId":"nsu=urn:plc;s=Profile","isArray":true}""");
AssertRoundTrip(DriverTypeNames.OpcUaClient, new[] { variable });
}
[Fact]
public void Export_header_matches_the_driver_column_dictionary()
{