diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusEquipmentTagParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusEquipmentTagParser.cs
index a8ef9e5e..893a9155 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusEquipmentTagParser.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusEquipmentTagParser.cs
@@ -35,9 +35,13 @@ public static class ModbusEquipmentTagParser
var bitIndex = (byte)ReadInt(root, "bitIndex");
var stringLength = (ushort)ReadInt(root, "stringLength");
// isArray / arrayLength — optional keys authored by the typed Modbus tag editor.
- // When arrayLength > 0 we expose an array tag of that count; otherwise scalar.
+ // Canonical rule: a tag is an array iff isArray:true AND arrayLength >= 1.
+ // isArray:false (with any arrayLength) is scalar — the foundation materialises a
+ // scalar OPC UA node, so returning an array value would cause a shape mismatch.
+ // A 1-element array (isArray:true, arrayLength:1) IS valid and reads as short[1].
+ var isArray = ReadBool(root, "isArray");
var arrayLength = ReadInt(root, "arrayLength");
- int? arrayCount = arrayLength > 0 ? arrayLength : null;
+ int? arrayCount = (isArray && arrayLength >= 1) ? arrayLength : null;
def = new ModbusTagDefinition(
Name: reference, Region: region, Address: (ushort)address, DataType: dataType,
Writable: true, ByteOrder: byteOrder, BitIndex: bitIndex, StringLength: stringLength,
@@ -56,4 +60,7 @@ public static class ModbusEquipmentTagParser
private static int ReadInt(JsonElement o, string name)
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.Number
&& e.TryGetInt32(out var v) ? v : 0;
+
+ private static bool ReadBool(JsonElement o, string name)
+ => o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.True;
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusArrayTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusArrayTests.cs
index 7fde3cb6..889a80f0 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusArrayTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusArrayTests.cs
@@ -332,6 +332,81 @@ public sealed class ModbusArrayTests
def!.ArrayCount.ShouldBeNull();
}
+ // ---- C-1 regression tests: isArray gate ----
+
+ ///
+ /// C-1 regression: isArray:false with a non-zero arrayLength must produce
+ /// ArrayCount == null (scalar), NOT an array. The foundation materialises a scalar
+ /// node when isArray is false; if the driver returned an array the value-shape
+ /// would mismatch and the OPC UA write would be rejected.
+ ///
+ [Fact]
+ public void Parser_IsArrayFalse_With_ArrayLength_Gives_Null_ArrayCount()
+ {
+ // isArray:false, arrayLength:8 → must be scalar (ArrayCount == null).
+ var json = """{"region":"HoldingRegisters","address":0,"dataType":"Int16","byteOrder":"BigEndian","bitIndex":0,"stringLength":0,"isArray":false,"arrayLength":8}""";
+ ModbusEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
+ def!.ArrayCount.ShouldBeNull();
+ }
+
+ ///
+ /// C-1 regression: isArray:false with arrayLength:8 must read as a SCALAR,
+ /// not as an 8-element array.
+ ///
+ [Fact]
+ public async Task Equipment_Tag_IsArrayFalse_With_ArrayLength_Reads_As_Scalar()
+ {
+ // isArray:false, arrayLength:8 → scalar read; only register[40] is consumed.
+ var json = """{"region":"HoldingRegisters","address":40,"dataType":"Int16","byteOrder":"BigEndian","bitIndex":0,"stringLength":0,"isArray":false,"arrayLength":8}""";
+ var fake = new ModbusDriverTests.FakeTransport();
+ var opts = new ModbusDriverOptions { Host = "fake", Tags = [] };
+ var drv = new ModbusDriver(opts, "modbus-c1-scalar", _ => fake);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ fake.HoldingRegisters[40] = 9999;
+
+ var r = await drv.ReadAsync([json], CancellationToken.None);
+
+ r[0].StatusCode.ShouldBe(0u);
+ // Must be a scalar short, NOT a short[].
+ r[0].Value.ShouldBeOfType();
+ r[0].Value.ShouldBe((short)9999);
+ }
+
+ ///
+ /// Verifies that isArray:true, arrayLength:1 produces ArrayCount == 1
+ /// (a valid 1-element array — the canonical rule says arrayLength >= 1 is valid).
+ ///
+ [Fact]
+ public void Parser_IsArrayTrue_ArrayLength1_Gives_ArrayCount_1()
+ {
+ var json = """{"region":"HoldingRegisters","address":0,"dataType":"Int16","byteOrder":"BigEndian","bitIndex":0,"stringLength":0,"isArray":true,"arrayLength":1}""";
+ ModbusEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
+ def!.ArrayCount.ShouldBe(1);
+ }
+
+ ///
+ /// Verifies that isArray:true, arrayLength:1 reads as a 1-ELEMENT array (not a
+ /// scalar). The foundation materialises a [1] OPC UA array node for this tag.
+ ///
+ [Fact]
+ public async Task Equipment_Tag_IsArrayTrue_ArrayLength1_Reads_As_One_Element_Array()
+ {
+ var json = """{"region":"HoldingRegisters","address":50,"dataType":"Int16","byteOrder":"BigEndian","bitIndex":0,"stringLength":0,"isArray":true,"arrayLength":1}""";
+ var fake = new ModbusDriverTests.FakeTransport();
+ var opts = new ModbusDriverOptions { Host = "fake", Tags = [] };
+ var drv = new ModbusDriver(opts, "modbus-c1-arr1", _ => fake);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ fake.HoldingRegisters[50] = 777;
+
+ var r = await drv.ReadAsync([json], CancellationToken.None);
+
+ r[0].StatusCode.ShouldBe(0u);
+ // Must be a short[] of length 1, NOT a scalar short.
+ var arr = r[0].Value.ShouldBeOfType();
+ arr.Length.ShouldBe(1);
+ arr[0].ShouldBe((short)777);
+ }
+
/// Recording address space builder for capturing discovered attributes.
/// List to capture discovered attributes into.
private sealed class RecordingBuilder(List captured) : IAddressSpaceBuilder