feat(abcip): 1-D array read via libplctag + IsArray discovery

This commit is contained in:
Joseph Doherty
2026-06-16 21:55:20 -04:00
parent a82c22c645
commit f4d5a5ee9c
8 changed files with 408 additions and 15 deletions
@@ -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 &lt;= 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>