feat(ablegacy): PCCC multi-element file array read + IsArray discovery

This commit is contained in:
Joseph Doherty
2026-06-16 21:55:41 -04:00
parent f4d5a5ee9c
commit 950069392c
7 changed files with 448 additions and 15 deletions
@@ -41,13 +41,31 @@ public sealed record AbLegacyDeviceOptions(
/// One PCCC-backed OPC UA variable. <paramref name="Address"/> is the canonical PCCC
/// file-address string that parses via <c>AbLegacyAddress.TryParse</c>.
/// </summary>
/// <param name="ArrayLength">
/// Element count when the tag addresses a multi-element span of a PCCC data file (e.g. an
/// <c>N7</c> integer file is inherently up to 256 words); <see langword="null"/> for a scalar.
/// A PCCC data file holds at most <see cref="AbLegacyArray.MaxElements"/> (256) elements, so a
/// value above that is clamped where it is materialised/read. <c>1</c> reads as a scalar.
/// </param>
public sealed record AbLegacyTagDefinition(
string Name,
string DeviceHostAddress,
string Address,
AbLegacyDataType DataType,
bool Writable = true,
bool WriteIdempotent = false);
bool WriteIdempotent = false,
int? ArrayLength = null);
/// <summary>PCCC array-tag constants shared by the parser, discovery, and read paths.</summary>
public static class AbLegacyArray
{
/// <summary>
/// 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.
/// </summary>
public const int MaxElements = 256;
}
public sealed class AbLegacyProbeOptions
{
@@ -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;
}
@@ -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
}
}
/// <summary>
/// Phase 4c #137 — the effective libplctag element count for a tag definition: the tag's
/// <see cref="AbLegacyTagDefinition.ArrayLength"/> clamped to the PCCC file maximum
/// (<see cref="AbLegacyArray.MaxElements"/> = 256), or <c>1</c> 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.
/// </summary>
private static int EffectiveArrayLength(AbLegacyTagDefinition def) =>
def.ArrayLength is int len && len > 1 ? Math.Min(len, AbLegacyArray.MaxElements) : 1;
private async Task<IAbLegacyTagRuntime> 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);
@@ -27,6 +27,18 @@ public interface IAbLegacyTagRuntime : IDisposable
/// <param name="bitIndex">Optional bit index for bit-level access.</param>
object? DecodeValue(AbLegacyDataType type, int? bitIndex);
/// <summary>
/// Decodes <paramref name="count"/> consecutive elements of a multi-element PCCC file
/// read as a typed CLR array (<c>short[]</c> for Int/AnalogInt, <c>int[]</c> for Long,
/// <c>float[]</c> for Float, <c>bool[]</c> for Bit), boxed as <see cref="object"/>. The
/// runtime must have been created with a matching element count (see
/// <see cref="AbLegacyTagCreateParams.ElementCount"/>) so the underlying buffer holds all
/// <paramref name="count"/> elements.
/// </summary>
/// <param name="type">The PCCC element data type.</param>
/// <param name="count">The number of elements to decode.</param>
object? DecodeArray(AbLegacyDataType type, int count);
/// <summary>Encodes a value for writing to the tag.</summary>
/// <param name="type">The data type to encode.</param>
/// <param name="bitIndex">Optional bit index for bit-level access.</param>
@@ -41,10 +53,23 @@ public interface IAbLegacyTagFactory
IAbLegacyTagRuntime Create(AbLegacyTagCreateParams createParams);
}
/// <param name="Gateway">The PLC gateway host (IP or DNS).</param>
/// <param name="Port">The EtherNet/IP TCP port.</param>
/// <param name="CipPath">The CIP routing path to the PLC (e.g. <c>1,0</c>).</param>
/// <param name="LibplctagPlcAttribute">The libplctag <c>plc</c> attribute (e.g. <c>slc500</c>).</param>
/// <param name="TagName">The PCCC file address passed to libplctag's <c>name</c> attribute.</param>
/// <param name="Timeout">The read/write operation timeout.</param>
/// <param name="ElementCount">
/// libplctag element count for the tag. <c>1</c> (the default) reads a single PCCC file
/// element (scalar). A value &gt; 1 reads that many consecutive elements from the base
/// address (e.g. <c>N7:0</c> with an element count of 5 reads <c>N7:0</c>..<c>N7:4</c>),
/// which the runtime surfaces via <see cref="IAbLegacyTagRuntime.DecodeArray"/>.
/// </param>
public sealed record AbLegacyTagCreateParams(
string Gateway,
int Port,
string CipPath,
string LibplctagPlcAttribute,
string TagName,
TimeSpan Timeout);
TimeSpan Timeout,
int ElementCount = 1);
@@ -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,
};
/// <inheritdoc />
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.");
}
}
/// <inheritdoc />
public void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value)
{
@@ -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;
/// <summary>
/// Phase 4c #137 array support for the AbLegacy (PCCC/DF1) driver. A PCCC data file
/// (e.g. <c>N7</c>) is inherently an array of up to 256 words; an equipment tag carrying an
/// <c>arrayLength</c> reads <c>count</c> consecutive file elements from the base address
/// (<c>N7:0</c> reading <c>count</c> words) via libplctag's native element-count and surfaces
/// them as a typed CLR array. Proven here against <see cref="FakeAbLegacyTag"/>; there is no
/// live AbLegacy fixture on this machine.
/// </summary>
[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 ----
/// <summary>An N-file (16-bit integer) array tag reads a <c>short[]</c>.</summary>
[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<short[]>();
arr.ShouldBe(new short[] { 100, 101, 102, 103, 104 });
}
/// <summary>An L-file (32-bit long) array tag reads an <c>int[]</c>.</summary>
[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<int[]>();
arr.ShouldBe(new[] { 70000, 80000, 90000 });
}
/// <summary>An F-file (32-bit float) array tag reads a <c>float[]</c>.</summary>
[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<float[]>();
arr.ShouldBe(new[] { 1.5f, 2.5f, 3.5f, 4.5f });
}
/// <summary>A B-file (bit) array tag reads a <c>bool[]</c>.</summary>
[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<bool[]>();
arr.ShouldBe(new[] { true, false, true, false, true, false });
}
/// <summary>The element count flows into the create params so libplctag reads N words.</summary>
[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);
}
/// <summary>A read failure on an array tag surfaces the mapped Bad status, not a partial array.</summary>
[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 ----
/// <summary>A scalar tag (no ArrayLength) still reads a single value, not an array.</summary>
[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<short[]>();
factory.Tags["N7:0"].CreationParams.ElementCount.ShouldBe(1);
}
/// <summary>An ArrayLength of 1 behaves like a scalar (single value, ElementCount 1).</summary>
[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 ----
/// <summary>Discovery flips IsArray and surfaces ArrayDim for an array file tag.</summary>
[Fact]
public async Task Discovery_surfaces_IsArray_and_ArrayDim_for_array_tag()
{
var captured = new List<DriverAttributeInfo>();
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);
}
/// <summary>Scalar tag discovery keeps IsArray false / ArrayDim null (regression guard).</summary>
[Fact]
public async Task Discovery_keeps_scalar_tag_non_array()
{
var captured = new List<DriverAttributeInfo>();
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();
}
/// <summary>Discovery caps a 1024-element tag's ArrayDim at the PCCC 256-word file maximum.</summary>
[Fact]
public async Task Discovery_caps_array_dim_at_256()
{
var captured = new List<DriverAttributeInfo>();
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 ----
/// <summary>The equipment-tag parser threads <c>arrayLength</c> into the transient definition.</summary>
[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);
}
/// <summary>The parser caps <c>arrayLength</c> at the PCCC 256-word file maximum.</summary>
[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);
}
/// <summary>A scalar equipment tag (no arrayLength) leaves ArrayLength null.</summary>
[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();
}
/// <summary>
/// End-to-end: an AbLegacy driver with NO authored tags reads an equipment-tag ref whose
/// TagConfig carries <c>arrayLength</c> — the resolver threads the count and the read
/// surfaces a typed array, capping the libplctag element count at 256.
/// </summary>
[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<short[]>();
arr.ShouldBe(new short[] { 11, 22, 33 });
factory.Tags["N7:0"].CreationParams.ElementCount.ShouldBe(3);
}
private sealed class RecordingBuilder(List<DriverAttributeInfo> 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) { }
}
}
}
@@ -10,6 +10,13 @@ internal class FakeAbLegacyTag : IAbLegacyTagRuntime
/// <summary>Gets or sets the tag value.</summary>
public object? Value { get; set; }
/// <summary>
/// Gets or sets the typed CLR array returned by <see cref="DecodeArray"/>. Set this to a
/// <c>short[]</c> / <c>int[]</c> / <c>float[]</c> / <c>bool[]</c> to simulate a libplctag
/// multi-element PCCC file read; the driver boxes it straight through.
/// </summary>
public object? ArrayValue { get; set; }
/// <summary>Gets or sets the tag status code.</summary>
public int Status { get; set; }
@@ -81,6 +88,12 @@ internal class FakeAbLegacyTag : IAbLegacyTagRuntime
/// <returns>The decoded value.</returns>
public virtual object? DecodeValue(AbLegacyDataType type, int? bitIndex) => Value;
/// <summary>Decodes <paramref name="count"/> elements as a typed CLR array.</summary>
/// <param name="type">The AbLegacy data type.</param>
/// <param name="count">The element count.</param>
/// <returns>The pre-set <see cref="ArrayValue"/>.</returns>
public virtual object? DecodeArray(AbLegacyDataType type, int count) => ArrayValue;
/// <summary>Encodes the tag value based on the specified data type and bit index.</summary>
/// <param name="type">The AbLegacy data type.</param>
/// <param name="bitIndex">The bit index if applicable.</param>