diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs
index d93a7fa6..34cb4294 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs
@@ -41,13 +41,31 @@ public sealed record AbLegacyDeviceOptions(
/// One PCCC-backed OPC UA variable. is the canonical PCCC
/// file-address string that parses via AbLegacyAddress.TryParse.
///
+///
+/// Element count when the tag addresses a multi-element span of a PCCC data file (e.g. an
+/// N7 integer file is inherently up to 256 words); for a scalar.
+/// A PCCC data file holds at most (256) elements, so a
+/// value above that is clamped where it is materialised/read. 1 reads as a scalar.
+///
public sealed record AbLegacyTagDefinition(
string Name,
string DeviceHostAddress,
string Address,
AbLegacyDataType DataType,
bool Writable = true,
- bool WriteIdempotent = false);
+ bool WriteIdempotent = false,
+ int? ArrayLength = null);
+
+/// PCCC array-tag constants shared by the parser, discovery, and read paths.
+public static class AbLegacyArray
+{
+ ///
+ /// Maximum element count for a single PCCC data file. The PCCC/DF1 protocol addresses a
+ /// data file element with a single byte sub-element offset, so a file holds at most 256
+ /// elements (words for N/B/I/O/S/A, 32-bit elements for L/F). Array tags clamp to this.
+ ///
+ public const int MaxElements = 256;
+}
public sealed class AbLegacyProbeOptions
{
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyEquipmentTagParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyEquipmentTagParser.cs
index 3f4f96e6..ad9beea0 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyEquipmentTagParser.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyEquipmentTagParser.cs
@@ -29,9 +29,18 @@ public static class AbLegacyEquipmentTagParser
if (string.IsNullOrWhiteSpace(address)) return false;
var dataType = ReadEnum(root, "dataType", AbLegacyDataType.Int);
var deviceHostAddress = ReadString(root, "deviceHostAddress");
+ // Phase 4c #137 — thread the equipment tag's array element count. arrayLength is the
+ // authoritative count; isArray is the AdminUI's boolean toggle. A positive arrayLength
+ // (regardless of isArray) makes this an array tag. Clamp to the PCCC file maximum
+ // (AbLegacyArray.MaxElements = 256) so a fat-fingered count can never request a span
+ // larger than a single data file holds. Absent / non-positive → null (scalar).
+ int? arrayLength = null;
+ var rawLength = ReadInt(root, "arrayLength");
+ if (rawLength > 0)
+ arrayLength = Math.Min(rawLength, AbLegacyArray.MaxElements);
def = new AbLegacyTagDefinition(
Name: reference, DeviceHostAddress: deviceHostAddress, Address: address,
- DataType: dataType, Writable: true);
+ DataType: dataType, Writable: true, ArrayLength: arrayLength);
return true;
}
catch (JsonException) { return false; }
@@ -46,4 +55,8 @@ public static class AbLegacyEquipmentTagParser
private static string ReadString(JsonElement o, string name)
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String
? e.GetString() ?? "" : "";
+
+ 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;
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
index 5b6a311c..d6fce5db 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
@@ -267,7 +267,17 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
await runtime.ReadAsync(cancellationToken).ConfigureAwait(false);
status = runtime.GetStatus();
var parsed = AbLegacyAddress.TryParse(def.Address);
- value = status == 0 ? runtime.DecodeValue(def.DataType, parsed?.BitIndex) : null;
+ // Phase 4c #137 — when the tag addresses a multi-element span (ArrayLength > 1)
+ // decode the whole contiguous read into a typed CLR array; otherwise decode a
+ // single scalar value as before. The runtime was created with a matching
+ // ElementCount in EnsureTagRuntimeAsync so its buffer holds all the elements.
+ var arrayLen = EffectiveArrayLength(def);
+ if (status != 0)
+ value = null;
+ else if (arrayLen > 1)
+ value = runtime.DecodeArray(def.DataType, arrayLen);
+ else
+ value = runtime.DecodeValue(def.DataType, parsed?.BitIndex);
}
finally
{
@@ -428,19 +438,20 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
foreach (var tag in tagsForDevice)
{
- // Driver.AbLegacy-013 (tracked follow-up) — PCCC files are inherently arrays of
- // elements (a single N7 file is up to 256 words), but the current tag-definition
- // surface only addresses one element. IsArray/ArrayDim are hard-wired false/null
- // until multi-element addressing lands; tags that genuinely span a range have to
- // be enumerated one element at a time today. This is consistent with the
- // PR-staged scope documented in docs/v2/driver-specs.md (AbLegacy ships with thin
- // array coverage); when array support is added, ArrayCount on the tag definition
- // will flow through here as it already does on the Modbus driver.
+ // Phase 4c #137 — PCCC data files are inherently arrays of elements (a single N7
+ // file is up to 256 words). A tag whose ArrayLength addresses a multi-element span
+ // now materialises a 1-D array OPC UA node. ArrayDim is clamped to the PCCC file
+ // maximum (AbLegacyArray.MaxElements = 256) so the declared dimension can never
+ // exceed what a single data file holds; an ArrayLength of 1 (or null) stays scalar.
+ var isArray = tag.ArrayLength is int len && len > 1;
+ var arrayDim = isArray
+ ? (uint)Math.Min(tag.ArrayLength!.Value, AbLegacyArray.MaxElements)
+ : (uint?)null;
deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
FullName: tag.Name,
DriverDataType: tag.DataType.ToDriverDataType(),
- IsArray: false,
- ArrayDim: null,
+ IsArray: isArray,
+ ArrayDim: arrayDim,
SecurityClass: tag.Writable
? SecurityClassification.Operate
: SecurityClassification.ViewOnly,
@@ -674,6 +685,16 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
}
}
+ ///
+ /// Phase 4c #137 — the effective libplctag element count for a tag definition: the tag's
+ /// clamped to the PCCC file maximum
+ /// ( = 256), or 1 when the tag is scalar
+ /// (null or non-positive ArrayLength). Used both to size the runtime at create time and to
+ /// decide whether the read path decodes a scalar or an array.
+ ///
+ private static int EffectiveArrayLength(AbLegacyTagDefinition def) =>
+ def.ArrayLength is int len && len > 1 ? Math.Min(len, AbLegacyArray.MaxElements) : 1;
+
private async Task EnsureTagRuntimeAsync(
DeviceState device, AbLegacyTagDefinition def, CancellationToken ct)
{
@@ -698,7 +719,11 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
CipPath: device.EffectiveCipPath,
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
TagName: parsed.ToLibplctagName(),
- Timeout: _options.Timeout));
+ Timeout: _options.Timeout,
+ // Phase 4c #137 — multi-element PCCC file read. A multi-element span (ArrayLength
+ // > 1) creates the libplctag tag with that element count so a single read fetches
+ // the whole array from the base address; scalar tags pass 1 and read unchanged.
+ ElementCount: EffectiveArrayLength(def)));
try
{
await runtime.InitializeAsync(ct).ConfigureAwait(false);
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs
index 7f1921fa..0dadf95b 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs
@@ -27,6 +27,18 @@ public interface IAbLegacyTagRuntime : IDisposable
/// Optional bit index for bit-level access.
object? DecodeValue(AbLegacyDataType type, int? bitIndex);
+ ///
+ /// Decodes consecutive elements of a multi-element PCCC file
+ /// read as a typed CLR array (short[] for Int/AnalogInt, int[] for Long,
+ /// float[] for Float, bool[] for Bit), boxed as . The
+ /// runtime must have been created with a matching element count (see
+ /// ) so the underlying buffer holds all
+ /// elements.
+ ///
+ /// The PCCC element data type.
+ /// The number of elements to decode.
+ object? DecodeArray(AbLegacyDataType type, int count);
+
/// Encodes a value for writing to the tag.
/// The data type to encode.
/// Optional bit index for bit-level access.
@@ -41,10 +53,23 @@ public interface IAbLegacyTagFactory
IAbLegacyTagRuntime Create(AbLegacyTagCreateParams createParams);
}
+/// The PLC gateway host (IP or DNS).
+/// The EtherNet/IP TCP port.
+/// The CIP routing path to the PLC (e.g. 1,0).
+/// The libplctag plc attribute (e.g. slc500).
+/// The PCCC file address passed to libplctag's name attribute.
+/// The read/write operation timeout.
+///
+/// libplctag element count for the tag. 1 (the default) reads a single PCCC file
+/// element (scalar). A value > 1 reads that many consecutive elements from the base
+/// address (e.g. N7:0 with an element count of 5 reads N7:0..N7:4),
+/// which the runtime surfaces via .
+///
public sealed record AbLegacyTagCreateParams(
string Gateway,
int Port,
string CipPath,
string LibplctagPlcAttribute,
string TagName,
- TimeSpan Timeout);
+ TimeSpan Timeout,
+ int ElementCount = 1);
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs
index 7caf3c50..548013f7 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs
@@ -24,6 +24,14 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
Protocol = Protocol.ab_eip, // PCCC-over-EIP; libplctag routes via the PlcType-specific PCCC layer
Name = p.TagName,
Timeout = p.Timeout,
+ // Phase 4c #137 — multi-element PCCC file read. ElementCount tells libplctag how many
+ // consecutive elements to fetch from the base address (e.g. N7:0 with ElementCount=5
+ // reads N7:0..N7:4 in one PCCC transaction). ElementCount=1 (the scalar default) leaves
+ // libplctag's own per-name element inference untouched, so scalar tags read exactly as
+ // before. ASSUMPTION: libplctag's ab_pccc layer honours elem_count for SLC/PLC-5 data
+ // files and lays the elements out contiguously in the tag buffer (verified against the
+ // libplctag.NET API surface; not live-proven — no PCCC fixture on this build host).
+ ElementCount = p.ElementCount > 1 ? p.ElementCount : null,
};
}
@@ -58,6 +66,51 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
_ => null,
};
+ ///
+ public object? DecodeArray(AbLegacyDataType type, int count)
+ {
+ // Each element is read from its byte offset within the contiguous tag buffer. PCCC word
+ // files (N/A → Int) are 2 bytes/element; L (Long) and F (Float) are 4 bytes/element. Bit
+ // (B-file) arrays read individual bits by index — libplctag's ab_pccc layer exposes the
+ // file's bits via GetBit(bitOffset). These element sizes are the canonical PCCC element
+ // widths; ASSUMPTION (not live-proven): libplctag packs multi-element reads contiguously
+ // with no per-element padding, matching the AbCip sibling's offset-decode pattern.
+ switch (type)
+ {
+ case AbLegacyDataType.Int:
+ case AbLegacyDataType.AnalogInt:
+ {
+ var arr = new short[count];
+ for (var i = 0; i < count; i++) arr[i] = _tag.GetInt16(i * 2);
+ return arr;
+ }
+ case AbLegacyDataType.Long:
+ {
+ var arr = new int[count];
+ for (var i = 0; i < count; i++) arr[i] = _tag.GetInt32(i * 4);
+ return arr;
+ }
+ case AbLegacyDataType.Float:
+ {
+ var arr = new float[count];
+ for (var i = 0; i < count; i++) arr[i] = _tag.GetFloat32(i * 4);
+ return arr;
+ }
+ case AbLegacyDataType.Bit:
+ {
+ var arr = new bool[count];
+ for (var i = 0; i < count; i++) arr[i] = _tag.GetBit(i);
+ return arr;
+ }
+ default:
+ // String / Timer / Counter / Control element arrays are not supported — these
+ // are structured or variable-width PCCC element types that don't lay out as a
+ // flat scalar array. The driver rejects them before reaching here.
+ throw new NotSupportedException(
+ $"AbLegacyDataType {type} is not supported as an array element.");
+ }
+ }
+
///
public void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value)
{
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayTests.cs
new file mode 100644
index 00000000..f41908f8
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayTests.cs
@@ -0,0 +1,286 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
+
+///
+/// Phase 4c #137 array support for the AbLegacy (PCCC/DF1) driver. A PCCC data file
+/// (e.g. N7) is inherently an array of up to 256 words; an equipment tag carrying an
+/// arrayLength reads count consecutive file elements from the base address
+/// (N7:0 reading count words) via libplctag's native element-count and surfaces
+/// them as a typed CLR array. Proven here against ; there is no
+/// live AbLegacy fixture on this machine.
+///
+[Trait("Category", "Unit")]
+public sealed class AbLegacyArrayTests
+{
+ private static (AbLegacyDriver drv, FakeAbLegacyTagFactory factory) NewDriver(params AbLegacyTagDefinition[] tags)
+ {
+ var factory = new FakeAbLegacyTagFactory();
+ var drv = new AbLegacyDriver(new AbLegacyDriverOptions
+ {
+ Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
+ Tags = tags,
+ Probe = new AbLegacyProbeOptions { Enabled = false },
+ }, "ablegacy-array", factory);
+ return (drv, factory);
+ }
+
+ // ---- Read: typed CLR arrays ----
+
+ /// An N-file (16-bit integer) array tag reads a short[].
+ [Fact]
+ public async Task Read_N_file_array_returns_short_array()
+ {
+ var (drv, factory) = NewDriver(
+ new AbLegacyTagDefinition("Levels", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int, ArrayLength: 5));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ factory.Customise = p => new FakeAbLegacyTag(p) { ArrayValue = new short[] { 100, 101, 102, 103, 104 } };
+
+ var snapshots = await drv.ReadAsync(["Levels"], CancellationToken.None);
+
+ snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
+ var arr = snapshots.Single().Value.ShouldBeOfType();
+ arr.ShouldBe(new short[] { 100, 101, 102, 103, 104 });
+ }
+
+ /// An L-file (32-bit long) array tag reads an int[].
+ [Fact]
+ public async Task Read_L_file_array_returns_int_array()
+ {
+ var (drv, factory) = NewDriver(
+ new AbLegacyTagDefinition("Counts", "ab://10.0.0.5/1,0", "L9:0", AbLegacyDataType.Long, ArrayLength: 3));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ factory.Customise = p => new FakeAbLegacyTag(p) { ArrayValue = new[] { 70000, 80000, 90000 } };
+
+ var snapshots = await drv.ReadAsync(["Counts"], CancellationToken.None);
+
+ var arr = snapshots.Single().Value.ShouldBeOfType();
+ arr.ShouldBe(new[] { 70000, 80000, 90000 });
+ }
+
+ /// An F-file (32-bit float) array tag reads a float[].
+ [Fact]
+ public async Task Read_F_file_array_returns_float_array()
+ {
+ var (drv, factory) = NewDriver(
+ new AbLegacyTagDefinition("Temps", "ab://10.0.0.5/1,0", "F8:0", AbLegacyDataType.Float, ArrayLength: 4));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ factory.Customise = p => new FakeAbLegacyTag(p) { ArrayValue = new[] { 1.5f, 2.5f, 3.5f, 4.5f } };
+
+ var snapshots = await drv.ReadAsync(["Temps"], CancellationToken.None);
+
+ var arr = snapshots.Single().Value.ShouldBeOfType();
+ arr.ShouldBe(new[] { 1.5f, 2.5f, 3.5f, 4.5f });
+ }
+
+ /// A B-file (bit) array tag reads a bool[].
+ [Fact]
+ public async Task Read_B_file_array_returns_bool_array()
+ {
+ var (drv, factory) = NewDriver(
+ new AbLegacyTagDefinition("Flags", "ab://10.0.0.5/1,0", "B3:0", AbLegacyDataType.Bit, ArrayLength: 6));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ factory.Customise = p => new FakeAbLegacyTag(p) { ArrayValue = new[] { true, false, true, false, true, false } };
+
+ var snapshots = await drv.ReadAsync(["Flags"], CancellationToken.None);
+
+ var arr = snapshots.Single().Value.ShouldBeOfType();
+ arr.ShouldBe(new[] { true, false, true, false, true, false });
+ }
+
+ /// The element count flows into the create params so libplctag reads N words.
+ [Fact]
+ public async Task Array_tag_threads_element_count_into_create_params()
+ {
+ var (drv, factory) = NewDriver(
+ new AbLegacyTagDefinition("Levels", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int, ArrayLength: 5));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ factory.Customise = p => new FakeAbLegacyTag(p) { ArrayValue = new short[] { 1, 2, 3, 4, 5 } };
+
+ await drv.ReadAsync(["Levels"], CancellationToken.None);
+
+ factory.Tags["N7:0"].CreationParams.ElementCount.ShouldBe(5);
+ }
+
+ /// A read failure on an array tag surfaces the mapped Bad status, not a partial array.
+ [Fact]
+ public async Task Array_read_failure_surfaces_bad_status()
+ {
+ var (drv, factory) = NewDriver(
+ new AbLegacyTagDefinition("Levels", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int, ArrayLength: 5));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ factory.Customise = p => new FakeAbLegacyTag(p) { Status = (int)libplctag.Status.ErrorTimeout };
+
+ var snapshots = await drv.ReadAsync(["Levels"], CancellationToken.None);
+
+ snapshots.Single().Value.ShouldBeNull();
+ snapshots.Single().StatusCode.ShouldNotBe(AbLegacyStatusMapper.Good);
+ }
+
+ // ---- Scalar regression ----
+
+ /// A scalar tag (no ArrayLength) still reads a single value, not an array.
+ [Fact]
+ public async Task Scalar_tag_still_reads_single_value()
+ {
+ var (drv, factory) = NewDriver(
+ new AbLegacyTagDefinition("Counter", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ factory.Customise = p => new FakeAbLegacyTag(p) { Value = 42 };
+
+ var snapshots = await drv.ReadAsync(["Counter"], CancellationToken.None);
+
+ snapshots.Single().Value.ShouldBe(42);
+ snapshots.Single().Value.ShouldNotBeOfType();
+ factory.Tags["N7:0"].CreationParams.ElementCount.ShouldBe(1);
+ }
+
+ /// An ArrayLength of 1 behaves like a scalar (single value, ElementCount 1).
+ [Fact]
+ public async Task ArrayLength_of_one_behaves_like_scalar()
+ {
+ var (drv, factory) = NewDriver(
+ new AbLegacyTagDefinition("Counter", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int, ArrayLength: 1));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ factory.Customise = p => new FakeAbLegacyTag(p) { Value = 7 };
+
+ var snapshots = await drv.ReadAsync(["Counter"], CancellationToken.None);
+
+ snapshots.Single().Value.ShouldBe(7);
+ factory.Tags["N7:0"].CreationParams.ElementCount.ShouldBe(1);
+ }
+
+ // ---- Discovery ----
+
+ /// Discovery flips IsArray and surfaces ArrayDim for an array file tag.
+ [Fact]
+ public async Task Discovery_surfaces_IsArray_and_ArrayDim_for_array_tag()
+ {
+ var captured = new List();
+ var drv = new AbLegacyDriver(new AbLegacyDriverOptions
+ {
+ Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
+ Tags = [new AbLegacyTagDefinition("Vector", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int, ArrayLength: 8)],
+ Probe = new AbLegacyProbeOptions { Enabled = false },
+ }, "ablegacy-array", new FakeAbLegacyTagFactory());
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ await drv.DiscoverAsync(new RecordingBuilder(captured), CancellationToken.None);
+
+ captured.Count.ShouldBe(1);
+ captured[0].IsArray.ShouldBeTrue();
+ captured[0].ArrayDim.ShouldBe(8u);
+ }
+
+ /// Scalar tag discovery keeps IsArray false / ArrayDim null (regression guard).
+ [Fact]
+ public async Task Discovery_keeps_scalar_tag_non_array()
+ {
+ var captured = new List();
+ var (drv, _) = NewDriver(
+ new AbLegacyTagDefinition("Single", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ await drv.DiscoverAsync(new RecordingBuilder(captured), CancellationToken.None);
+
+ captured[0].IsArray.ShouldBeFalse();
+ captured[0].ArrayDim.ShouldBeNull();
+ }
+
+ /// Discovery caps a 1024-element tag's ArrayDim at the PCCC 256-word file maximum.
+ [Fact]
+ public async Task Discovery_caps_array_dim_at_256()
+ {
+ var captured = new List();
+ var (drv, _) = NewDriver(
+ new AbLegacyTagDefinition("Big", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int, ArrayLength: 1024));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ await drv.DiscoverAsync(new RecordingBuilder(captured), CancellationToken.None);
+
+ captured[0].IsArray.ShouldBeTrue();
+ captured[0].ArrayDim.ShouldBe(256u);
+ }
+
+ // ---- Equipment-tag resolver threads arrayLength ----
+
+ /// The equipment-tag parser threads arrayLength into the transient definition.
+ [Fact]
+ public void EquipmentTagParser_threads_arrayLength()
+ {
+ var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","address":"N7:0","dataType":"Int","isArray":true,"arrayLength":10}""";
+ AbLegacyEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
+ def!.ArrayLength.ShouldBe(10);
+ }
+
+ /// The parser caps arrayLength at the PCCC 256-word file maximum.
+ [Fact]
+ public void EquipmentTagParser_caps_arrayLength_at_256()
+ {
+ var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","address":"N7:0","dataType":"Int","arrayLength":99999}""";
+ AbLegacyEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
+ def!.ArrayLength.ShouldBe(256);
+ }
+
+ /// A scalar equipment tag (no arrayLength) leaves ArrayLength null.
+ [Fact]
+ public void EquipmentTagParser_scalar_leaves_arrayLength_null()
+ {
+ var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","address":"N7:0","dataType":"Int"}""";
+ AbLegacyEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
+ def!.ArrayLength.ShouldBeNull();
+ }
+
+ ///
+ /// End-to-end: an AbLegacy driver with NO authored tags reads an equipment-tag ref whose
+ /// TagConfig carries arrayLength — the resolver threads the count and the read
+ /// surfaces a typed array, capping the libplctag element count at 256.
+ ///
+ [Fact]
+ public async Task Driver_reads_equipment_array_ref_as_typed_array()
+ {
+ var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","address":"N7:0","dataType":"Int","arrayLength":3}""";
+ var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { ArrayValue = new short[] { 11, 22, 33 } } };
+ var drv = new AbLegacyDriver(new AbLegacyDriverOptions
+ {
+ Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
+ Tags = [],
+ Probe = new AbLegacyProbeOptions { Enabled = false },
+ }, "ablegacy-eq-array", factory);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var r = await drv.ReadAsync([json], CancellationToken.None);
+
+ r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
+ var arr = r[0].Value.ShouldBeOfType();
+ arr.ShouldBe(new short[] { 11, 22, 33 });
+ factory.Tags["N7:0"].CreationParams.ElementCount.ShouldBe(3);
+ }
+
+ private sealed class RecordingBuilder(List captured) : IAddressSpaceBuilder
+ {
+ public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
+
+ public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
+ {
+ captured.Add(info);
+ return new Handle(info.FullName);
+ }
+
+ public void AddProperty(string name, DriverDataType dataType, object? value) { }
+
+ 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.AbLegacy.Tests/FakeAbLegacyTag.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/FakeAbLegacyTag.cs
index 42528da7..68dfb4b6 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/FakeAbLegacyTag.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/FakeAbLegacyTag.cs
@@ -10,6 +10,13 @@ internal class FakeAbLegacyTag : IAbLegacyTagRuntime
/// Gets or sets the tag value.
public object? Value { get; set; }
+ ///
+ /// Gets or sets the typed CLR array returned by . Set this to a
+ /// short[] / int[] / float[] / bool[] to simulate a libplctag
+ /// multi-element PCCC file read; the driver boxes it straight through.
+ ///
+ public object? ArrayValue { get; set; }
+
/// Gets or sets the tag status code.
public int Status { get; set; }
@@ -81,6 +88,12 @@ internal class FakeAbLegacyTag : IAbLegacyTagRuntime
/// The decoded value.
public virtual object? DecodeValue(AbLegacyDataType type, int? bitIndex) => Value;
+ /// Decodes elements as a typed CLR array.
+ /// The AbLegacy data type.
+ /// The element count.
+ /// The pre-set .
+ public virtual object? DecodeArray(AbLegacyDataType type, int count) => ArrayValue;
+
/// Encodes the tag value based on the specified data type and bit index.
/// The AbLegacy data type.
/// The bit index if applicable.