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)
{