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 709ddc0d..7de88b4d 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 @@ -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 diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvMapper.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvMapper.cs index b521cf58..c9d39f80 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvMapper.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvMapper.cs @@ -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. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttTagDefinitionFactoryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttTagDefinitionFactoryTests.cs index 74a41eec..7c4ac4c0 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttTagDefinitionFactoryTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttTagDefinitionFactoryTests.cs @@ -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() { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTagCsvRoundTripTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTagCsvRoundTripTests.cs index 31dc679f..634d07e0 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTagCsvRoundTripTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTagCsvRoundTripTests.cs @@ -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() {