diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs
index 2ff250db..66417010 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs
@@ -135,6 +135,10 @@ public sealed record AbCipDeviceOptions(
/// GuardLogix controller; non-safety writes violate the safety-partition isolation and are
/// rejected by the PLC anyway. Surfaces the intent explicitly instead of relying on the
/// write attempt failing at runtime.
+/// Phase 4c — number of array elements for a 1-D array tag. Defaults
+/// to 1 (scalar). When greater than 1 the tag discovers as an OPC UA array node
+/// (IsArray + ArrayDim) and reads via libplctag's elem_count into an
+/// element-typed CLR array. Ignored for .
public sealed record AbCipTagDefinition(
string Name,
string DeviceHostAddress,
@@ -143,7 +147,8 @@ public sealed record AbCipTagDefinition(
bool Writable = true,
bool WriteIdempotent = false,
IReadOnlyList? Members = null,
- bool SafetyTag = false);
+ bool SafetyTag = false,
+ int ElementCount = 1);
///
/// One declared member of a UDT tag. Name is the member identifier on the PLC (e.g. Speed,
@@ -155,7 +160,8 @@ public sealed record AbCipStructureMember(
string Name,
AbCipDataType DataType,
bool Writable = true,
- bool WriteIdempotent = false);
+ bool WriteIdempotent = false,
+ int ElementCount = 1);
/// Which AB PLC family the device is — selects the profile applied to connection params.
public enum AbCipPlcFamily
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipEquipmentTagParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipEquipmentTagParser.cs
index de2bef82..8d590622 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipEquipmentTagParser.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipEquipmentTagParser.cs
@@ -31,9 +31,13 @@ public static class AbCipEquipmentTagParser
var deviceHostAddress = ReadString(root, "deviceHostAddress");
var dataType = ReadEnum(root, "dataType", AbCipDataType.DInt);
+ // Phase 4c — an isArray equipment tag carries arrayLength; thread it into the def's
+ // ElementCount so the read pulls the whole array via libplctag elem_count. When
+ // isArray is absent/false (or arrayLength is missing/<=1) the tag stays scalar.
+ var elementCount = ReadArrayElementCount(root);
def = new AbCipTagDefinition(
Name: reference, DeviceHostAddress: deviceHostAddress, TagPath: tagPath,
- DataType: dataType, Writable: true);
+ DataType: dataType, Writable: true, ElementCount: elementCount);
return true;
}
catch (JsonException) { return false; }
@@ -41,6 +45,23 @@ public static class AbCipEquipmentTagParser
catch (InvalidOperationException) { return false; }
}
+ ///
+ /// Resolve the 1-D array element count from an isArray / arrayLength pair.
+ /// Returns 1 (scalar) unless isArray is truthy AND arrayLength is a number
+ /// greater than 1; matches the sink's "isArray + arrayLength" carrier.
+ ///
+ private static int ReadArrayElementCount(JsonElement o)
+ {
+ var isArray = o.TryGetProperty("isArray", out var a) && a.ValueKind == JsonValueKind.True;
+ if (!isArray) return 1;
+ if (o.TryGetProperty("arrayLength", out var len)
+ && len.ValueKind == JsonValueKind.Number
+ && len.TryGetInt32(out var n)
+ && n > 1)
+ return n;
+ return 1;
+ }
+
private static TEnum ReadEnum(JsonElement o, string name, TEnum fallback) where TEnum : struct, Enum
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String
&& Enum.TryParse(e.GetString(), ignoreCase: true, out var v) ? v : fallback;
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
index ff04db65..b69a2a3c 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
@@ -553,7 +553,11 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var tagPath = AbCipTagPath.TryParse(def.TagPath);
var bitIndex = tagPath?.BitIndex;
- var value = runtime.DecodeValue(def.DataType, bitIndex);
+ // Phase 4c — a 1-D array tag decodes the whole buffer into an element-typed CLR
+ // array (int[]/float[]/bool[]/string[]…); scalar tags keep the single-value path.
+ var value = def.ElementCount > 1
+ ? runtime.DecodeArray(def.DataType, def.ElementCount)
+ : runtime.DecodeValue(def.DataType, bitIndex);
results[fb.OriginalIndex] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now);
_health = new DriverHealth(DriverState.Healthy, now, null);
}
@@ -850,7 +854,11 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
?? throw new InvalidOperationException(
$"AbCip tag '{def.Name}' has malformed TagPath '{def.TagPath}'.");
- var runtime = _tagFactory.Create(device.BuildCreateParams(parsed.ToLibplctagName(), _options.Timeout));
+ // Phase 4c — a 1-D array tag (ElementCount > 1) sets libplctag's elem_count so the read
+ // pulls every element in one CIP transaction; the read path then boxes them into a
+ // typed CLR array. Scalar tags pass the default count of 1, unchanged.
+ var runtime = _tagFactory.Create(
+ device.BuildCreateParams(parsed.ToLibplctagName(), _options.Timeout, def.ElementCount));
try
{
await runtime.InitializeAsync(ct).ConfigureAwait(false);
@@ -945,8 +953,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
udtFolder.Variable(member.Name, member.Name, new DriverAttributeInfo(
FullName: memberFullName,
DriverDataType: member.DataType.ToDriverDataType(),
- IsArray: false,
- ArrayDim: null,
+ IsArray: member.ElementCount > 1,
+ ArrayDim: member.ElementCount > 1 ? (uint)member.ElementCount : null,
SecurityClass: member.Writable
? SecurityClassification.Operate
: SecurityClassification.ViewOnly,
@@ -983,8 +991,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
discoveredFolder.Variable(fullName, discovered.Name, new DriverAttributeInfo(
FullName: fullName,
DriverDataType: discovered.DataType.ToDriverDataType(),
- IsArray: false,
- ArrayDim: null,
+ IsArray: discovered.ElementCount > 1,
+ ArrayDim: discovered.ElementCount > 1 ? (uint)discovered.ElementCount : null,
SecurityClass: discovered.ReadOnly
? SecurityClassification.ViewOnly
: SecurityClassification.Operate,
@@ -999,8 +1007,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
private static DriverAttributeInfo ToAttributeInfo(AbCipTagDefinition tag) => new(
FullName: tag.Name,
DriverDataType: tag.DataType.ToDriverDataType(),
- IsArray: false,
- ArrayDim: null,
+ IsArray: tag.ElementCount > 1,
+ ArrayDim: tag.ElementCount > 1 ? (uint)tag.ElementCount : null,
SecurityClass: (tag.Writable && !tag.SafetyTag)
? SecurityClassification.Operate
: SecurityClassification.ViewOnly,
@@ -1102,8 +1110,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
///
/// The name of the tag to create parameters for.
/// The timeout for tag operations.
+ /// libplctag elem_count — 1 for a scalar tag, the array
+ /// length for a 1-D array tag (Phase 4c). Coerced to a minimum of 1.
/// The computed tag creation parameters.
- public AbCipTagCreateParams BuildCreateParams(string tagName, TimeSpan timeout) => new(
+ public AbCipTagCreateParams BuildCreateParams(string tagName, TimeSpan timeout, int elementCount = 1) => new(
Gateway: ParsedAddress.Gateway,
Port: ParsedAddress.Port,
CipPath: ParsedAddress.CipPath,
@@ -1111,7 +1121,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
TagName: tagName,
Timeout: timeout,
AllowPacking: Options.AllowPacking ?? Profile.SupportsRequestPacking,
- ConnectionSize: Options.ConnectionSize ?? Profile.DefaultConnectionSize);
+ ConnectionSize: Options.ConnectionSize ?? Profile.DefaultConnectionSize,
+ ElementCount: elementCount < 1 ? 1 : elementCount);
/// Disposes all runtime tag handles and clears the caches.
public void DisposeHandles()
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagEnumerator.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagEnumerator.cs
index 4f187b39..6bf42a06 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagEnumerator.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagEnumerator.cs
@@ -39,12 +39,16 @@ public interface IAbCipTagEnumeratorFactory
/// Hint from the enumerator that this is a system / infrastructure tag;
/// the driver applies on top so the enumerator is not the
/// single source of truth.
+/// Phase 4c — libplctag elem_count reported by the Symbol
+/// Object's array-dimension fields. Defaults to 1 (scalar); greater than 1 surfaces the tag
+/// as an OPC UA array node at discovery.
public sealed record AbCipDiscoveredTag(
string Name,
string? ProgramScope,
AbCipDataType DataType,
bool ReadOnly,
- bool IsSystemTag = false);
+ bool IsSystemTag = false,
+ int ElementCount = 1);
///
/// No-op enumerator returning an empty sequence. Useful for tests + strict-config
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs
index 536ec6fc..68d5f211 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs
@@ -50,6 +50,19 @@ public interface IAbCipTagRuntime : IDisposable
/// Bit index for BOOL-within-DINT extraction, or null.
object? DecodeValueAt(AbCipDataType type, int offset, int? bitIndex);
+ ///
+ /// Phase 4c — decode the local buffer into an element-typed CLR array of
+ /// elements, boxed as (e.g.
+ /// int[], float[], bool[], string[]). The driver calls this
+ /// for a 1-D array tag after a single array (libplctag pulls all
+ /// elements in one transaction via its elem_count). Each element is decoded at its
+ /// byte stride within the buffer; scalar (count <= 1) reads stay on
+ /// .
+ ///
+ /// CIP element data type to decode.
+ /// Number of array elements to decode.
+ object? DecodeArray(AbCipDataType type, int count);
+
///
/// Encode into the local buffer per the tag's type. Callers
/// pair this with .
@@ -85,6 +98,10 @@ public interface IAbCipTagFactory
/// (if any) with the family profile's DefaultConnectionSize. libplctag 1.5.2 has no
/// direct ConnectionSize property; the value is plumbed for forward-compat with future
/// wrappers / a custom tag-attribute path (Driver.AbCip-013).
+/// Phase 4c — libplctag elem_count. Forwarded to the
+/// libplctag Tag.ElementCount property so a 1-D array tag pulls all elements in one
+/// CIP transaction. Defaults to 1 (scalar); the driver sets it from the tag definition's
+/// element count for an isArray tag.
public sealed record AbCipTagCreateParams(
string Gateway,
int Port,
@@ -93,4 +110,5 @@ public sealed record AbCipTagCreateParams(
string TagName,
TimeSpan Timeout,
bool AllowPacking = true,
- int ConnectionSize = 4002);
+ int ConnectionSize = 4002,
+ int ElementCount = 1);
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs
index 3f3f46ca..0c88f554 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs
@@ -28,6 +28,9 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
// Driver.AbCip-013 — honour the per-device or family-default AllowPacking knob so
// operators can disable CIP request-packing for older firmware or a single device.
AllowPacking = p.AllowPacking,
+ // Phase 4c — libplctag elem_count. For a 1-D array tag the driver passes the element
+ // count so libplctag pulls every element in one CIP read; scalar tags pass 1.
+ ElementCount = p.ElementCount > 1 ? p.ElementCount : 1,
};
// ConnectionSize is captured on AbCipTagCreateParams for forward-compat (driver-specs.md
// exposes it as a per-device option) but libplctag.NET 1.5.2 has no direct Tag property
@@ -87,6 +90,79 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
_ => null,
};
+ ///
+ /// Phase 4c — decode array elements from the post-read buffer
+ /// into an element-typed CLR array. libplctag has already pulled all elements (the tag
+ /// was created with elem_count = count); we slice the local buffer per element at
+ /// byte stride using the same per-offset decoders the
+ /// scalar / UDT-member paths use. The boxed result is a strongly-typed array
+ /// (int[], float[], bool[], string[], …) so the OPC UA layer
+ /// materialises a 1-D array variant.
+ ///
+ /// The element data type to decode.
+ /// The number of elements to decode.
+ /// A boxed element-typed CLR array, or null for an unsupported element type.
+ public object? DecodeArray(AbCipDataType type, int count)
+ {
+ if (count < 1) count = 1;
+ // libplctag reports the per-element byte size once the tag has been read; it correctly
+ // accounts for STRING capacity + atomic widths. ElementSize is int? on the wrapper — fall
+ // back to a static size table when it is null/<=0 (e.g. a zero-length read) so the stride
+ // is never 0.
+ var elementSize = _tag.ElementSize ?? 0;
+ var stride = elementSize > 0 ? elementSize : ElementByteSize(type);
+
+ return type switch
+ {
+ // A Logix BOOL array is bit-packed on the wire; libplctag exposes each element via
+ // GetBit(elementIndex) rather than a byte stride, so decode bit-by-bit.
+ AbCipDataType.Bool => BuildBoolArray(count),
+ AbCipDataType.SInt or AbCipDataType.USInt or AbCipDataType.Int or AbCipDataType.UInt
+ or AbCipDataType.DInt or AbCipDataType.Dt => BuildArray(count, stride, type),
+ AbCipDataType.UDInt => BuildArray(count, stride, type),
+ AbCipDataType.LInt => BuildArray(count, stride, type),
+ AbCipDataType.ULInt => BuildArray(count, stride, type),
+ AbCipDataType.Real => BuildArray(count, stride, type),
+ AbCipDataType.LReal => BuildArray(count, stride, type),
+ AbCipDataType.String => BuildArray(count, stride, type),
+ _ => null,
+ };
+ }
+
+ private bool[] BuildBoolArray(int count)
+ {
+ var arr = new bool[count];
+ for (var i = 0; i < count; i++) arr[i] = _tag.GetBit(i);
+ return arr;
+ }
+
+ private T[] BuildArray(int count, int stride, AbCipDataType type)
+ {
+ var arr = new T[count];
+ for (var i = 0; i < count; i++)
+ {
+ var decoded = DecodeValueAt(type, i * stride, null);
+ // DecodeValueAt boxes to the element CLR type that ToDriverDataType maps to, which is
+ // exactly T for every branch above; an explicit cast keeps the array strongly typed.
+ arr[i] = decoded is null ? default! : (T)decoded;
+ }
+ return arr;
+ }
+
+ /// Static fallback byte stride per atomic element, used only when libplctag has not
+ /// yet populated . STRING falls back to the Logix STRING wire
+ /// size (4-byte LEN + 82-byte DATA, 88 with alignment) — but libplctag.NET's
+ /// ElementSize is the real source of truth at runtime.
+ private static int ElementByteSize(AbCipDataType type) => type switch
+ {
+ AbCipDataType.Bool or AbCipDataType.SInt or AbCipDataType.USInt => 1,
+ AbCipDataType.Int or AbCipDataType.UInt => 2,
+ AbCipDataType.DInt or AbCipDataType.UDInt or AbCipDataType.Real or AbCipDataType.Dt => 4,
+ AbCipDataType.LInt or AbCipDataType.ULInt or AbCipDataType.LReal => 8,
+ AbCipDataType.String => 88,
+ _ => 4,
+ };
+
/// Encodes the specified value to the tag with the specified data type.
/// The data type to encode.
/// The bit index for bit-level access, if applicable.
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipArrayTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipArrayTests.cs
new file mode 100644
index 00000000..4ea40fc6
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipArrayTests.cs
@@ -0,0 +1,229 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
+
+///
+/// Phase 4c (Task 6) — 1-D array support for AbCip. Covers: discovery flips
+/// /
+/// for an array atomic tag + an array UDT member; the read path returns a typed CLR array
+/// boxed as ; and the equipment-tag resolver threads
+/// arrayLength from the TagConfig into the transient definition's element count so
+/// an isArray equipment tag reads the whole array.
+///
+[Trait("Category", "Unit")]
+public sealed class AbCipArrayTests
+{
+ private static (AbCipDriver drv, FakeAbCipTagFactory factory) NewDriver(params AbCipTagDefinition[] tags)
+ {
+ var factory = new FakeAbCipTagFactory();
+ var opts = new AbCipDriverOptions
+ {
+ Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
+ Tags = tags,
+ };
+ var drv = new AbCipDriver(opts, "drv-array", factory);
+ return (drv, factory);
+ }
+
+ // ---- Discovery: IsArray / ArrayDim flips ----
+
+ /// An atomic pre-declared tag with ElementCount > 1 discovers as a 1-D array.
+ [Fact]
+ public async Task PreDeclared_array_tag_discovers_as_IsArray_with_ArrayDim()
+ {
+ var builder = new RecordingBuilder();
+ var (drv, _) = NewDriver(
+ new AbCipTagDefinition("Recipe", "ab://10.0.0.5/1,0", "Recipe", AbCipDataType.DInt, ElementCount: 10),
+ new AbCipTagDefinition("Single", "ab://10.0.0.5/1,0", "Single", AbCipDataType.DInt));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ await drv.DiscoverAsync(builder, CancellationToken.None);
+
+ var arr = builder.Variables.Single(v => v.BrowseName == "Recipe").Info;
+ arr.IsArray.ShouldBeTrue();
+ arr.ArrayDim.ShouldBe(10u);
+
+ var scalar = builder.Variables.Single(v => v.BrowseName == "Single").Info;
+ scalar.IsArray.ShouldBeFalse();
+ scalar.ArrayDim.ShouldBeNull();
+ }
+
+ /// A UDT member with ElementCount > 1 discovers as a 1-D array variable.
+ [Fact]
+ public async Task Udt_array_member_discovers_as_IsArray_with_ArrayDim()
+ {
+ var builder = new RecordingBuilder();
+ var (drv, _) = NewDriver(
+ new AbCipTagDefinition("Motor", "ab://10.0.0.5/1,0", "Motor", AbCipDataType.Structure,
+ Members:
+ [
+ new AbCipStructureMember("Setpoints", AbCipDataType.Real, ElementCount: 4),
+ new AbCipStructureMember("Speed", AbCipDataType.DInt),
+ ]));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ await drv.DiscoverAsync(builder, CancellationToken.None);
+
+ var member = builder.Variables.Single(v => v.BrowseName == "Setpoints").Info;
+ member.IsArray.ShouldBeTrue();
+ member.ArrayDim.ShouldBe(4u);
+
+ var scalarMember = builder.Variables.Single(v => v.BrowseName == "Speed").Info;
+ scalarMember.IsArray.ShouldBeFalse();
+ scalarMember.ArrayDim.ShouldBeNull();
+ }
+
+ // ---- Read: typed CLR array ----
+
+ /// An array DInt tag reads as a boxed int[] of the configured element count.
+ [Fact]
+ public async Task Array_DInt_read_returns_typed_int_array()
+ {
+ var (drv, factory) = NewDriver(
+ new AbCipTagDefinition("Recipe", "ab://10.0.0.5/1,0", "Recipe", AbCipDataType.DInt, ElementCount: 4));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ factory.Customise = p => new ArrayFakeAbCipTag(p, new int[] { 11, 22, 33, 44 });
+
+ var snapshots = await drv.ReadAsync(["Recipe"], CancellationToken.None);
+
+ snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
+ var value = snapshots.Single().Value.ShouldBeOfType();
+ value.ShouldBe([11, 22, 33, 44]);
+ // libplctag element count must be threaded to the runtime params.
+ factory.Tags["Recipe"].CreationParams.ElementCount.ShouldBe(4);
+ }
+
+ /// An array Real tag reads as a boxed float[].
+ [Fact]
+ public async Task Array_Real_read_returns_typed_float_array()
+ {
+ var (drv, factory) = NewDriver(
+ new AbCipTagDefinition("Floats", "ab://10.0.0.5/1,0", "Floats", AbCipDataType.Real, ElementCount: 3));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ factory.Customise = p => new ArrayFakeAbCipTag(p, new float[] { 1.5f, 2.5f, 3.5f });
+
+ var snapshots = await drv.ReadAsync(["Floats"], CancellationToken.None);
+
+ var value = snapshots.Single().Value.ShouldBeOfType();
+ value.ShouldBe([1.5f, 2.5f, 3.5f]);
+ }
+
+ /// An array Bool tag reads as a boxed bool[].
+ [Fact]
+ public async Task Array_Bool_read_returns_typed_bool_array()
+ {
+ var (drv, factory) = NewDriver(
+ new AbCipTagDefinition("Flags", "ab://10.0.0.5/1,0", "Flags", AbCipDataType.Bool, ElementCount: 3));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ factory.Customise = p => new ArrayFakeAbCipTag(p, new bool[] { true, false, true });
+
+ var snapshots = await drv.ReadAsync(["Flags"], CancellationToken.None);
+
+ var value = snapshots.Single().Value.ShouldBeOfType();
+ value.ShouldBe([true, false, true]);
+ }
+
+ /// An array String tag reads as a boxed string[].
+ [Fact]
+ public async Task Array_String_read_returns_typed_string_array()
+ {
+ var (drv, factory) = NewDriver(
+ new AbCipTagDefinition("Names", "ab://10.0.0.5/1,0", "Names", AbCipDataType.String, ElementCount: 2));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ factory.Customise = p => new ArrayFakeAbCipTag(p, new string[] { "a", "b" });
+
+ var snapshots = await drv.ReadAsync(["Names"], CancellationToken.None);
+
+ var value = snapshots.Single().Value.ShouldBeOfType();
+ value.ShouldBe(["a", "b"]);
+ }
+
+ /// A scalar tag (ElementCount 1) is unaffected — still a boxed scalar, not an array.
+ [Fact]
+ public async Task Scalar_read_path_unchanged_for_element_count_one()
+ {
+ var (drv, factory) = NewDriver(
+ new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ factory.Customise = p => new FakeAbCipTag(p) { Value = 4200 };
+
+ var snapshots = await drv.ReadAsync(["Speed"], CancellationToken.None);
+
+ snapshots.Single().Value.ShouldBe(4200);
+ snapshots.Single().Value.ShouldNotBeOfType();
+ }
+
+ // ---- Resolver: arrayLength threading ----
+
+ /// The equipment-tag resolver threads arrayLength into the def's ElementCount.
+ [Fact]
+ public async Task Equipment_ref_with_arrayLength_reads_as_a_typed_array()
+ {
+ var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","tagPath":"Recipe","dataType":"DInt","isArray":true,"arrayLength":4}""";
+ var factory = new FakeAbCipTagFactory();
+ var opts = new AbCipDriverOptions
+ {
+ Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
+ Tags = [],
+ };
+ var drv = new AbCipDriver(opts, "abcip-eq-array", factory);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ factory.Customise = p => new ArrayFakeAbCipTag(p, new int[] { 7, 8, 9, 10 });
+
+ var snapshots = await drv.ReadAsync([json], CancellationToken.None);
+
+ snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
+ var value = snapshots.Single().Value.ShouldBeOfType();
+ value.ShouldBe([7, 8, 9, 10]);
+ factory.Tags["Recipe"].CreationParams.ElementCount.ShouldBe(4);
+ }
+
+ /// The parser threads arrayLength into the transient definition's ElementCount.
+ [Fact]
+ public void Parser_threads_arrayLength_into_ElementCount()
+ {
+ var json = """{"tagPath":"Recipe","dataType":"DInt","isArray":true,"arrayLength":8}""";
+ AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
+ def!.ElementCount.ShouldBe(8);
+ }
+
+ /// A non-array equipment ref defaults ElementCount to 1 (scalar).
+ [Fact]
+ public void Parser_defaults_ElementCount_to_one_when_not_an_array()
+ {
+ var json = """{"tagPath":"Recipe","dataType":"DInt"}""";
+ AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
+ def!.ElementCount.ShouldBe(1);
+ }
+
+ // ---- helpers ----
+
+ /// Minimal recorder for the discovery assertions.
+ private sealed class RecordingBuilder : IAddressSpaceBuilder
+ {
+ public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
+ public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
+
+ public IAddressSpaceBuilder Folder(string browseName, string displayName)
+ { Folders.Add((browseName, displayName)); return this; }
+
+ public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
+ { Variables.Add((browseName, info)); return new Handle(info.FullName); }
+
+ public void AddProperty(string _, DriverDataType __, object? ___) { }
+
+ private sealed class Handle(string fullRef) : IVariableHandle
+ {
+ public string FullReference => fullRef;
+ public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
+ }
+
+ private sealed class NullSink : IAlarmConditionSink
+ {
+ public void OnTransition(AlarmEventArgs args) { }
+ }
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/FakeAbCipTag.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/FakeAbCipTag.cs
index 2e437a1d..91986a20 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/FakeAbCipTag.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/FakeAbCipTag.cs
@@ -88,6 +88,20 @@ internal class FakeAbCipTag : IAbCipTagRuntime
return offset == 0 ? Value : null;
}
+ ///
+ /// Phase 4c array-read seam. Returns (the boxed element-typed
+ /// CLR array a test seeds) regardless of / ,
+ /// so a test asserts that the driver routes a 1-D array tag through this method instead of
+ /// the scalar . is the convenient
+ /// constructor-seeded variant.
+ ///
+ /// The element data type being decoded.
+ /// The number of elements to decode.
+ public virtual object? DecodeArray(AbCipDataType type, int count) => ArrayValue;
+
+ /// Gets or sets the boxed array value returned from .
+ public object? ArrayValue { get; set; }
+
/// Encodes a value into the mock tag storage.
/// The data type being encoded.
/// The optional bit index for bit operations.
@@ -98,6 +112,20 @@ internal class FakeAbCipTag : IAbCipTagRuntime
public virtual void Dispose() => Disposed = true;
}
+///
+/// A pre-seeded with a boxed element-typed CLR array, returned
+/// from . Mirrors a real libplctag array read where
+/// elem_count elements come back in one transaction.
+///
+internal sealed class ArrayFakeAbCipTag : FakeAbCipTag
+{
+ /// Initializes the fake with a boxed array value.
+ /// The tag creation parameters.
+ /// The boxed element-typed CLR array the read returns.
+ public ArrayFakeAbCipTag(AbCipTagCreateParams createParams, object array) : base(createParams)
+ => ArrayValue = array;
+}
+
/// Test factory that produces s and indexes them for assertion.
internal sealed class FakeAbCipTagFactory : IAbCipTagFactory
{