feat(abcip): 1-D array read via libplctag + IsArray discovery
This commit is contained in:
@@ -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.</param>
|
||||
/// <param name="ElementCount">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
|
||||
/// (<c>IsArray</c> + <c>ArrayDim</c>) and reads via libplctag's <c>elem_count</c> into an
|
||||
/// element-typed CLR array. Ignored for <see cref="AbCipDataType.Structure"/>.</param>
|
||||
public sealed record AbCipTagDefinition(
|
||||
string Name,
|
||||
string DeviceHostAddress,
|
||||
@@ -143,7 +147,8 @@ public sealed record AbCipTagDefinition(
|
||||
bool Writable = true,
|
||||
bool WriteIdempotent = false,
|
||||
IReadOnlyList<AbCipStructureMember>? Members = null,
|
||||
bool SafetyTag = false);
|
||||
bool SafetyTag = false,
|
||||
int ElementCount = 1);
|
||||
|
||||
/// <summary>
|
||||
/// One declared member of a UDT tag. Name is the member identifier on the PLC (e.g. <c>Speed</c>,
|
||||
@@ -155,7 +160,8 @@ public sealed record AbCipStructureMember(
|
||||
string Name,
|
||||
AbCipDataType DataType,
|
||||
bool Writable = true,
|
||||
bool WriteIdempotent = false);
|
||||
bool WriteIdempotent = false,
|
||||
int ElementCount = 1);
|
||||
|
||||
/// <summary>Which AB PLC family the device is — selects the profile applied to connection params.</summary>
|
||||
public enum AbCipPlcFamily
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the 1-D array element count from an <c>isArray</c> / <c>arrayLength</c> pair.
|
||||
/// Returns 1 (scalar) unless <c>isArray</c> is truthy AND <c>arrayLength</c> is a number
|
||||
/// greater than 1; matches the sink's "isArray + arrayLength" carrier.
|
||||
/// </summary>
|
||||
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<TEnum>(JsonElement o, string name, TEnum fallback) where TEnum : struct, Enum
|
||||
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String
|
||||
&& Enum.TryParse<TEnum>(e.GetString(), ignoreCase: true, out var v) ? v : fallback;
|
||||
|
||||
@@ -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,
|
||||
/// </summary>
|
||||
/// <param name="tagName">The name of the tag to create parameters for.</param>
|
||||
/// <param name="timeout">The timeout for tag operations.</param>
|
||||
/// <param name="elementCount">libplctag <c>elem_count</c> — 1 for a scalar tag, the array
|
||||
/// length for a 1-D array tag (Phase 4c). Coerced to a minimum of 1.</param>
|
||||
/// <returns>The computed tag creation parameters.</returns>
|
||||
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);
|
||||
|
||||
/// <summary>Disposes all runtime tag handles and clears the caches.</summary>
|
||||
public void DisposeHandles()
|
||||
|
||||
@@ -39,12 +39,16 @@ public interface IAbCipTagEnumeratorFactory
|
||||
/// <param name="IsSystemTag">Hint from the enumerator that this is a system / infrastructure tag;
|
||||
/// the driver applies <see cref="AbCipSystemTagFilter"/> on top so the enumerator is not the
|
||||
/// single source of truth.</param>
|
||||
/// <param name="ElementCount">Phase 4c — libplctag <c>elem_count</c> 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.</param>
|
||||
public sealed record AbCipDiscoveredTag(
|
||||
string Name,
|
||||
string? ProgramScope,
|
||||
AbCipDataType DataType,
|
||||
bool ReadOnly,
|
||||
bool IsSystemTag = false);
|
||||
bool IsSystemTag = false,
|
||||
int ElementCount = 1);
|
||||
|
||||
/// <summary>
|
||||
/// No-op enumerator returning an empty sequence. Useful for tests + strict-config
|
||||
|
||||
@@ -50,6 +50,19 @@ public interface IAbCipTagRuntime : IDisposable
|
||||
/// <param name="bitIndex">Bit index for BOOL-within-DINT extraction, or null.</param>
|
||||
object? DecodeValueAt(AbCipDataType type, int offset, int? bitIndex);
|
||||
|
||||
/// <summary>
|
||||
/// Phase 4c — decode the local buffer into an element-typed CLR array of
|
||||
/// <paramref name="count"/> elements, boxed as <see cref="object"/> (e.g.
|
||||
/// <c>int[]</c>, <c>float[]</c>, <c>bool[]</c>, <c>string[]</c>). The driver calls this
|
||||
/// for a 1-D array tag after a single array <see cref="ReadAsync"/> (libplctag pulls all
|
||||
/// elements in one transaction via its <c>elem_count</c>). Each element is decoded at its
|
||||
/// byte stride within the buffer; scalar (count <= 1) reads stay on
|
||||
/// <see cref="DecodeValue"/>.
|
||||
/// </summary>
|
||||
/// <param name="type">CIP element data type to decode.</param>
|
||||
/// <param name="count">Number of array elements to decode.</param>
|
||||
object? DecodeArray(AbCipDataType type, int count);
|
||||
|
||||
/// <summary>
|
||||
/// Encode <paramref name="value"/> into the local buffer per the tag's type. Callers
|
||||
/// pair this with <see cref="WriteAsync"/>.
|
||||
@@ -85,6 +98,10 @@ public interface IAbCipTagFactory
|
||||
/// (if any) with the family profile's <c>DefaultConnectionSize</c>. libplctag 1.5.2 has no
|
||||
/// direct <c>ConnectionSize</c> property; the value is plumbed for forward-compat with future
|
||||
/// wrappers / a custom tag-attribute path (Driver.AbCip-013).</param>
|
||||
/// <param name="ElementCount">Phase 4c — libplctag <c>elem_count</c>. Forwarded to the
|
||||
/// libplctag <c>Tag.ElementCount</c> 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 <c>isArray</c> tag.</param>
|
||||
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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Phase 4c — decode <paramref name="count"/> array elements from the post-read buffer
|
||||
/// into an element-typed CLR array. libplctag has already pulled all elements (the tag
|
||||
/// was created with <c>elem_count = count</c>); we slice the local buffer per element at
|
||||
/// byte stride <see cref="Tag.ElementSize"/> using the same per-offset decoders the
|
||||
/// scalar / UDT-member paths use. The boxed result is a strongly-typed array
|
||||
/// (<c>int[]</c>, <c>float[]</c>, <c>bool[]</c>, <c>string[]</c>, …) so the OPC UA layer
|
||||
/// materialises a 1-D array variant.
|
||||
/// </summary>
|
||||
/// <param name="type">The element data type to decode.</param>
|
||||
/// <param name="count">The number of elements to decode.</param>
|
||||
/// <returns>A boxed element-typed CLR array, or <c>null</c> for an unsupported element type.</returns>
|
||||
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<int>(count, stride, type),
|
||||
AbCipDataType.UDInt => BuildArray<uint>(count, stride, type),
|
||||
AbCipDataType.LInt => BuildArray<long>(count, stride, type),
|
||||
AbCipDataType.ULInt => BuildArray<ulong>(count, stride, type),
|
||||
AbCipDataType.Real => BuildArray<float>(count, stride, type),
|
||||
AbCipDataType.LReal => BuildArray<double>(count, stride, type),
|
||||
AbCipDataType.String => BuildArray<string>(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<T>(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;
|
||||
}
|
||||
|
||||
/// <summary>Static fallback byte stride per atomic element, used only when libplctag has not
|
||||
/// yet populated <see cref="Tag.ElementSize"/>. STRING falls back to the Logix STRING wire
|
||||
/// size (4-byte LEN + 82-byte DATA, 88 with alignment) — but libplctag.NET's
|
||||
/// <c>ElementSize</c> is the real source of truth at runtime.</summary>
|
||||
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,
|
||||
};
|
||||
|
||||
/// <summary>Encodes the specified value to the tag with the specified data type.</summary>
|
||||
/// <param name="type">The data type to encode.</param>
|
||||
/// <param name="bitIndex">The bit index for bit-level access, if applicable.</param>
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 4c (Task 6) — 1-D array support for AbCip. Covers: discovery flips
|
||||
/// <see cref="DriverAttributeInfo.IsArray"/> / <see cref="DriverAttributeInfo.ArrayDim"/>
|
||||
/// for an array atomic tag + an array UDT member; the read path returns a typed CLR array
|
||||
/// boxed as <see cref="object"/>; and the equipment-tag resolver threads
|
||||
/// <c>arrayLength</c> from the TagConfig into the transient definition's element count so
|
||||
/// an <c>isArray</c> equipment tag reads the whole array.
|
||||
/// </summary>
|
||||
[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 ----
|
||||
|
||||
/// <summary>An atomic pre-declared tag with ElementCount > 1 discovers as a 1-D array.</summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>A UDT member with ElementCount > 1 discovers as a 1-D array variable.</summary>
|
||||
[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 ----
|
||||
|
||||
/// <summary>An array DInt tag reads as a boxed int[] of the configured element count.</summary>
|
||||
[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<int[]>();
|
||||
value.ShouldBe([11, 22, 33, 44]);
|
||||
// libplctag element count must be threaded to the runtime params.
|
||||
factory.Tags["Recipe"].CreationParams.ElementCount.ShouldBe(4);
|
||||
}
|
||||
|
||||
/// <summary>An array Real tag reads as a boxed float[].</summary>
|
||||
[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<float[]>();
|
||||
value.ShouldBe([1.5f, 2.5f, 3.5f]);
|
||||
}
|
||||
|
||||
/// <summary>An array Bool tag reads as a boxed bool[].</summary>
|
||||
[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<bool[]>();
|
||||
value.ShouldBe([true, false, true]);
|
||||
}
|
||||
|
||||
/// <summary>An array String tag reads as a boxed string[].</summary>
|
||||
[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<string[]>();
|
||||
value.ShouldBe(["a", "b"]);
|
||||
}
|
||||
|
||||
/// <summary>A scalar tag (ElementCount 1) is unaffected — still a boxed scalar, not an array.</summary>
|
||||
[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<int[]>();
|
||||
}
|
||||
|
||||
// ---- Resolver: arrayLength threading ----
|
||||
|
||||
/// <summary>The equipment-tag resolver threads arrayLength into the def's ElementCount.</summary>
|
||||
[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<int[]>();
|
||||
value.ShouldBe([7, 8, 9, 10]);
|
||||
factory.Tags["Recipe"].CreationParams.ElementCount.ShouldBe(4);
|
||||
}
|
||||
|
||||
/// <summary>The parser threads arrayLength into the transient definition's ElementCount.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>A non-array equipment ref defaults ElementCount to 1 (scalar).</summary>
|
||||
[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 ----
|
||||
|
||||
/// <summary>Minimal <see cref="IAddressSpaceBuilder"/> recorder for the discovery assertions.</summary>
|
||||
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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,20 @@ internal class FakeAbCipTag : IAbCipTagRuntime
|
||||
return offset == 0 ? Value : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 4c array-read seam. Returns <see cref="ArrayValue"/> (the boxed element-typed
|
||||
/// CLR array a test seeds) regardless of <paramref name="type"/> / <paramref name="count"/>,
|
||||
/// so a test asserts that the driver routes a 1-D array tag through this method instead of
|
||||
/// the scalar <see cref="DecodeValue"/>. <see cref="ArrayFakeAbCipTag"/> is the convenient
|
||||
/// constructor-seeded variant.
|
||||
/// </summary>
|
||||
/// <param name="type">The element data type being decoded.</param>
|
||||
/// <param name="count">The number of elements to decode.</param>
|
||||
public virtual object? DecodeArray(AbCipDataType type, int count) => ArrayValue;
|
||||
|
||||
/// <summary>Gets or sets the boxed array value returned from <see cref="DecodeArray"/>.</summary>
|
||||
public object? ArrayValue { get; set; }
|
||||
|
||||
/// <summary>Encodes a value into the mock tag storage.</summary>
|
||||
/// <param name="type">The data type being encoded.</param>
|
||||
/// <param name="bitIndex">The optional bit index for bit operations.</param>
|
||||
@@ -98,6 +112,20 @@ internal class FakeAbCipTag : IAbCipTagRuntime
|
||||
public virtual void Dispose() => Disposed = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="FakeAbCipTag"/> pre-seeded with a boxed element-typed CLR array, returned
|
||||
/// from <see cref="DecodeArray"/>. Mirrors a real libplctag array read where
|
||||
/// <c>elem_count</c> elements come back in one transaction.
|
||||
/// </summary>
|
||||
internal sealed class ArrayFakeAbCipTag : FakeAbCipTag
|
||||
{
|
||||
/// <summary>Initializes the fake with a boxed array value.</summary>
|
||||
/// <param name="createParams">The tag creation parameters.</param>
|
||||
/// <param name="array">The boxed element-typed CLR array the read returns.</param>
|
||||
public ArrayFakeAbCipTag(AbCipTagCreateParams createParams, object array) : base(createParams)
|
||||
=> ArrayValue = array;
|
||||
}
|
||||
|
||||
/// <summary>Test factory that produces <see cref="FakeAbCipTag"/>s and indexes them for assertion.</summary>
|
||||
internal sealed class FakeAbCipTagFactory : IAbCipTagFactory
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user