feat(ablegacy): PCCC multi-element file array read + IsArray discovery
This commit is contained in:
@@ -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
|
||||
{
|
||||
|
||||
+14
-1
@@ -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 > 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>
|
||||
|
||||
Reference in New Issue
Block a user