Compare commits

...

6 Commits

Author SHA1 Message Date
dohertj2 345ac97c43 Merge pull request '[abcip] AbCip — Array-slice read addressing Tag[0..N]' (#319) from auto/abcip/1.3 into auto/driver-gaps 2026-04-25 13:06:11 -04:00
Joseph Doherty 767ac4aec5 Auto: abcip-1.3 — array-slice read addressing
Closes #227
2026-04-25 13:03:45 -04:00
dohertj2 29edd835a3 Merge pull request '[abcip] AbCip — STRINGnn variant decoding' (#318) from auto/abcip/1.2 into auto/driver-gaps 2026-04-25 12:55:34 -04:00
Joseph Doherty d78a471e90 Auto: abcip-1.2 — STRINGnn variant decoding
Closes #226

Adds nullable StringLength to AbCipTagDefinition + AbCipStructureMember
so STRING_20 / STRING_40 / STRING_80 UDT variants decode against the
right DATA-array capacity. The configured length threads through a new
StringMaxCapacity field on AbCipTagCreateParams and lands on the
libplctag Tag.StringMaxCapacity attribute (verified property on
libplctag 1.5.2). Null leaves libplctag's default 82-byte STRING in
place for back-compat. Driver gates on DataType == String so a stray
StringLength on a DINT tag doesn't reshape that buffer. UDT member
fan-out copies StringLength from the AbCipStructureMember onto the
synthesised member tag definition.

Tests: 4 new in AbCipDriverReadTests covering threaded StringMaxCapacity,
the null back-compat path, the non-String gate, and the UDT-member fan-out.
2026-04-25 12:53:20 -04:00
dohertj2 1d9e40236b Merge pull request '[abcip] AbCip — LINT/ULINT 64-bit fidelity' (#317) from auto/abcip/1.1 into auto/driver-gaps 2026-04-25 12:47:17 -04:00
Joseph Doherty 2e6228a243 Auto: abcip-1.1 — LINT/ULINT 64-bit fidelity
Closes #225
2026-04-25 12:44:43 -04:00
11 changed files with 589 additions and 14 deletions
@@ -0,0 +1,94 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// PR abcip-1.3 — issues one libplctag tag-create with <c>ElementCount=N</c> per Rockwell
/// array-slice tag (<c>Tag[0..N]</c> in <see cref="AbCipTagPath"/>), then decodes the
/// contiguous buffer at element stride into <c>N</c> typed values. Mirrors the whole-UDT
/// planner pattern (<see cref="AbCipUdtReadPlanner"/>): pure shape — the planner never
/// touches the runtime + never reads the PLC, the driver wires the runtime in.
/// </summary>
/// <remarks>
/// <para>Stride is the natural Logix size of the element type (DInt = 4, Real = 4, LInt = 8).
/// Bool / String / Structure slices aren't supported here — Logix packs BOOLs into a host
/// byte (no fixed stride), STRING members carry a Length+DATA pair that's not a flat array,
/// and structure arrays need the CIP Template Object reader (PR-tracked separately).</para>
///
/// <para>Output is a single <c>object[]</c> snapshot value containing the N decoded
/// elements at indices 0..Count-1. Pairing with one slice tag = one snapshot keeps the
/// <c>ReadAsync</c> 1:1 contract (one fullReference -> one snapshot) intact.</para>
/// </remarks>
public static class AbCipArrayReadPlanner
{
/// <summary>
/// Build the libplctag create-params + decode descriptor for a slice tag. Returns
/// <c>null</c> when the slice element type isn't supported under this declaration-only
/// decoder (Bool / String / Structure / unrecognised) — the driver falls back to the
/// scalar read path so the operator gets a clean per-element result instead.
/// </summary>
public static AbCipArrayReadPlan? TryBuild(
AbCipTagDefinition definition,
AbCipTagPath parsedPath,
AbCipTagCreateParams baseParams)
{
ArgumentNullException.ThrowIfNull(definition);
ArgumentNullException.ThrowIfNull(parsedPath);
ArgumentNullException.ThrowIfNull(baseParams);
if (parsedPath.Slice is null) return null;
if (!TryGetStride(definition.DataType, out var stride)) return null;
var slice = parsedPath.Slice;
var createParams = baseParams with
{
TagName = parsedPath.ToLibplctagSliceArrayName(),
ElementCount = slice.Count,
};
return new AbCipArrayReadPlan(definition.DataType, slice, stride, createParams);
}
/// <summary>
/// Decode <paramref name="plan"/>.Count elements from <paramref name="runtime"/> at
/// element stride. Caller has already invoked <see cref="IAbCipTagRuntime.ReadAsync"/>
/// and confirmed <see cref="IAbCipTagRuntime.GetStatus"/> == 0.
/// </summary>
public static object?[] Decode(AbCipArrayReadPlan plan, IAbCipTagRuntime runtime)
{
ArgumentNullException.ThrowIfNull(plan);
ArgumentNullException.ThrowIfNull(runtime);
var values = new object?[plan.Slice.Count];
for (var i = 0; i < plan.Slice.Count; i++)
values[i] = runtime.DecodeValueAt(plan.ElementType, i * plan.Stride, bitIndex: null);
return values;
}
private static bool TryGetStride(AbCipDataType type, out int stride)
{
switch (type)
{
case AbCipDataType.SInt: case AbCipDataType.USInt:
stride = 1; return true;
case AbCipDataType.Int: case AbCipDataType.UInt:
stride = 2; return true;
case AbCipDataType.DInt: case AbCipDataType.UDInt:
case AbCipDataType.Real: case AbCipDataType.Dt:
stride = 4; return true;
case AbCipDataType.LInt: case AbCipDataType.ULInt:
case AbCipDataType.LReal:
stride = 8; return true;
default:
stride = 0; return false;
}
}
}
/// <summary>
/// Plan output: the libplctag create-params for the single array-read tag plus the
/// element-type / stride / slice metadata the decoder needs.
/// </summary>
public sealed record AbCipArrayReadPlan(
AbCipDataType ElementType,
AbCipTagPathSlice Slice,
int Stride,
AbCipTagCreateParams CreateParams);
@@ -50,11 +50,12 @@ public static class AbCipDataTypeExtensions
AbCipDataType.Bool => DriverDataType.Boolean, AbCipDataType.Bool => DriverDataType.Boolean,
AbCipDataType.SInt or AbCipDataType.Int or AbCipDataType.DInt => DriverDataType.Int32, AbCipDataType.SInt or AbCipDataType.Int or AbCipDataType.DInt => DriverDataType.Int32,
AbCipDataType.USInt or AbCipDataType.UInt or AbCipDataType.UDInt => DriverDataType.Int32, AbCipDataType.USInt or AbCipDataType.UInt or AbCipDataType.UDInt => DriverDataType.Int32,
AbCipDataType.LInt or AbCipDataType.ULInt => DriverDataType.Int32, // TODO: Int64 — matches Modbus gap AbCipDataType.LInt => DriverDataType.Int64,
AbCipDataType.ULInt => DriverDataType.UInt64,
AbCipDataType.Real => DriverDataType.Float32, AbCipDataType.Real => DriverDataType.Float32,
AbCipDataType.LReal => DriverDataType.Float64, AbCipDataType.LReal => DriverDataType.Float64,
AbCipDataType.String => DriverDataType.String, AbCipDataType.String => DriverDataType.String,
AbCipDataType.Dt => DriverDataType.Int32, // epoch-seconds DINT AbCipDataType.Dt => DriverDataType.Int64, // Logix v32+ DT == LINT epoch-millis
AbCipDataType.Structure => DriverDataType.String, // placeholder until UDT PR 6 introduces a structured kind AbCipDataType.Structure => DriverDataType.String, // placeholder until UDT PR 6 introduces a structured kind
_ => DriverDataType.Int32, _ => DriverDataType.Int32,
}; };
@@ -134,7 +134,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
TagPath: $"{tag.TagPath}.{member.Name}", TagPath: $"{tag.TagPath}.{member.Name}",
DataType: member.DataType, DataType: member.DataType,
Writable: member.Writable, Writable: member.Writable,
WriteIdempotent: member.WriteIdempotent); WriteIdempotent: member.WriteIdempotent,
StringLength: member.StringLength);
_tagsByName[memberTag.Name] = memberTag; _tagsByName[memberTag.Name] = memberTag;
} }
} }
@@ -357,6 +358,17 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
return; return;
} }
// PR abcip-1.3 — array-slice path. A tag whose TagPath ends in [N..M] dispatches to
// AbCipArrayReadPlanner: one libplctag tag-create with ElementCount=N issues one
// Rockwell array read; the contiguous buffer is decoded at element stride into a
// single snapshot whose Value is an object[] of the N elements.
var parsedPath = AbCipTagPath.TryParse(def.TagPath);
if (parsedPath?.Slice is not null)
{
await ReadSliceAsync(fb, def, parsedPath, device, results, now, ct).ConfigureAwait(false);
return;
}
try try
{ {
var runtime = await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false); var runtime = await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false);
@@ -372,8 +384,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
return; return;
} }
var tagPath = AbCipTagPath.TryParse(def.TagPath); var bitIndex = parsedPath?.BitIndex;
var bitIndex = tagPath?.BitIndex;
var value = runtime.DecodeValue(def.DataType, bitIndex); var value = runtime.DecodeValue(def.DataType, bitIndex);
results[fb.OriginalIndex] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now); results[fb.OriginalIndex] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now);
_health = new DriverHealth(DriverState.Healthy, now, null); _health = new DriverHealth(DriverState.Healthy, now, null);
@@ -390,6 +401,89 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
} }
} }
/// <summary>
/// PR abcip-1.3 — slice read path. Builds an <see cref="AbCipArrayReadPlan"/> from the
/// parsed slice path, materialises a per-tag runtime keyed by the tag's full name (so
/// repeat reads reuse the same libplctag handle), issues one PLC array read, and
/// decodes the contiguous buffer into <c>object?[]</c> at element stride. Unsupported
/// element types fall back to <see cref="AbCipStatusMapper.BadNotSupported"/>.
/// </summary>
private async Task ReadSliceAsync(
AbCipUdtReadFallback fb, AbCipTagDefinition def, AbCipTagPath parsedPath,
DeviceState device, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
{
var baseParams = new AbCipTagCreateParams(
Gateway: device.ParsedAddress.Gateway,
Port: device.ParsedAddress.Port,
CipPath: device.ParsedAddress.CipPath,
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
TagName: parsedPath.ToLibplctagName(),
Timeout: _options.Timeout);
var plan = AbCipArrayReadPlanner.TryBuild(def, parsedPath, baseParams);
if (plan is null)
{
results[fb.OriginalIndex] = new DataValueSnapshot(null,
AbCipStatusMapper.BadNotSupported, null, now);
return;
}
try
{
var runtime = await EnsureSliceRuntimeAsync(device, def.Name, plan.CreateParams, ct)
.ConfigureAwait(false);
await runtime.ReadAsync(ct).ConfigureAwait(false);
var status = runtime.GetStatus();
if (status != 0)
{
results[fb.OriginalIndex] = new DataValueSnapshot(null,
AbCipStatusMapper.MapLibplctagStatus(status), null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
$"libplctag status {status} reading slice {def.Name}");
return;
}
var values = AbCipArrayReadPlanner.Decode(plan, runtime);
results[fb.OriginalIndex] = new DataValueSnapshot(values, AbCipStatusMapper.Good, now, now);
_health = new DriverHealth(DriverState.Healthy, now, null);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
results[fb.OriginalIndex] = new DataValueSnapshot(null,
AbCipStatusMapper.BadCommunicationError, null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
}
}
/// <summary>
/// Idempotently materialise a slice-read runtime. Slice runtimes share the device's
/// <see cref="DeviceState.Runtimes"/> dict keyed by the tag's full name so repeated
/// reads reuse the same libplctag handle without re-creating the native tag every poll.
/// </summary>
private async Task<IAbCipTagRuntime> EnsureSliceRuntimeAsync(
DeviceState device, string tagName, AbCipTagCreateParams createParams, CancellationToken ct)
{
if (device.Runtimes.TryGetValue(tagName, out var existing)) return existing;
var runtime = _tagFactory.Create(createParams);
try
{
await runtime.InitializeAsync(ct).ConfigureAwait(false);
}
catch
{
runtime.Dispose();
throw;
}
device.Runtimes[tagName] = runtime;
return runtime;
}
/// <summary> /// <summary>
/// Task #194 — perform one whole-UDT read on the parent tag, then decode each /// Task #194 — perform one whole-UDT read on the parent tag, then decode each
/// grouped member from the runtime's buffer at its computed byte offset. A per-group /// grouped member from the runtime's buffer at its computed byte offset. A per-group
@@ -633,7 +727,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
CipPath: device.ParsedAddress.CipPath, CipPath: device.ParsedAddress.CipPath,
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute, LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
TagName: parsed.ToLibplctagName(), TagName: parsed.ToLibplctagName(),
Timeout: _options.Timeout)); Timeout: _options.Timeout,
StringMaxCapacity: def.DataType == AbCipDataType.String ? def.StringLength : null));
try try
{ {
await runtime.InitializeAsync(ct).ConfigureAwait(false); await runtime.InitializeAsync(ct).ConfigureAwait(false);
@@ -92,6 +92,13 @@ public sealed record AbCipDeviceOptions(
/// GuardLogix controller; non-safety writes violate the safety-partition isolation and are /// 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 /// rejected by the PLC anyway. Surfaces the intent explicitly instead of relying on the
/// write attempt failing at runtime.</param> /// write attempt failing at runtime.</param>
/// <param name="StringLength">Capacity of the DATA character array on a Logix STRING / STRINGnn
/// UDT — 82 for the stock <c>STRING</c>, 20/40/80/etc for user-defined <c>STRING_20</c>,
/// <c>STRING_40</c>, <c>STRING_80</c> variants. Threads through libplctag's
/// <c>str_max_capacity</c> attribute so the wrapper allocates the correct backing buffer
/// and <c>GetString</c> / <c>SetString</c> truncate at the right boundary. <c>null</c>
/// keeps libplctag's default 82-byte STRING behaviour for back-compat. Ignored for
/// non-<see cref="AbCipDataType.String"/> types.</param>
public sealed record AbCipTagDefinition( public sealed record AbCipTagDefinition(
string Name, string Name,
string DeviceHostAddress, string DeviceHostAddress,
@@ -100,7 +107,8 @@ public sealed record AbCipTagDefinition(
bool Writable = true, bool Writable = true,
bool WriteIdempotent = false, bool WriteIdempotent = false,
IReadOnlyList<AbCipStructureMember>? Members = null, IReadOnlyList<AbCipStructureMember>? Members = null,
bool SafetyTag = false); bool SafetyTag = false,
int? StringLength = null);
/// <summary> /// <summary>
/// One declared member of a UDT tag. Name is the member identifier on the PLC (e.g. <c>Speed</c>, /// One declared member of a UDT tag. Name is the member identifier on the PLC (e.g. <c>Speed</c>,
@@ -112,7 +120,8 @@ public sealed record AbCipStructureMember(
string Name, string Name,
AbCipDataType DataType, AbCipDataType DataType,
bool Writable = true, bool Writable = true,
bool WriteIdempotent = false); bool WriteIdempotent = false,
int? StringLength = null);
/// <summary>Which AB PLC family the device is — selects the profile applied to connection params.</summary> /// <summary>Which AB PLC family the device is — selects the profile applied to connection params.</summary>
public enum AbCipPlcFamily public enum AbCipPlcFamily
@@ -20,7 +20,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
public sealed record AbCipTagPath( public sealed record AbCipTagPath(
string? ProgramScope, string? ProgramScope,
IReadOnlyList<AbCipTagPathSegment> Segments, IReadOnlyList<AbCipTagPathSegment> Segments,
int? BitIndex) int? BitIndex,
AbCipTagPathSlice? Slice = null)
{ {
/// <summary>Rebuild the canonical Logix tag string.</summary> /// <summary>Rebuild the canonical Logix tag string.</summary>
public string ToLibplctagName() public string ToLibplctagName()
@@ -37,10 +38,39 @@ public sealed record AbCipTagPath(
if (seg.Subscripts.Count > 0) if (seg.Subscripts.Count > 0)
buf.Append('[').Append(string.Join(",", seg.Subscripts)).Append(']'); buf.Append('[').Append(string.Join(",", seg.Subscripts)).Append(']');
} }
if (Slice is not null) buf.Append('[').Append(Slice.Start).Append("..").Append(Slice.End).Append(']');
if (BitIndex is not null) buf.Append('.').Append(BitIndex.Value); if (BitIndex is not null) buf.Append('.').Append(BitIndex.Value);
return buf.ToString(); return buf.ToString();
} }
/// <summary>
/// Logix-symbol form for issuing a single libplctag tag-create that reads the slice as a
/// contiguous buffer — i.e. the bare array name (with the start subscript) without the
/// <c>..End</c> suffix. The driver pairs this with <see cref="AbCipTagCreateParams.ElementCount"/>
/// = <see cref="AbCipTagPathSlice.Count"/> to issue a single Rockwell array read.
/// </summary>
public string ToLibplctagSliceArrayName()
{
if (Slice is null) return ToLibplctagName();
var buf = new System.Text.StringBuilder();
if (ProgramScope is not null)
buf.Append("Program:").Append(ProgramScope).Append('.');
for (var i = 0; i < Segments.Count; i++)
{
if (i > 0) buf.Append('.');
var seg = Segments[i];
buf.Append(seg.Name);
if (seg.Subscripts.Count > 0)
buf.Append('[').Append(string.Join(",", seg.Subscripts)).Append(']');
}
// Anchor the read at the slice start; libplctag treats Name=Tag[0] + ElementCount=N as
// "read N consecutive elements starting at index 0", which is the exact Rockwell
// array-read semantic this PR is wiring up.
buf.Append('[').Append(Slice.Start).Append(']');
return buf.ToString();
}
/// <summary> /// <summary>
/// Parse a Logix-symbolic tag reference. Returns <c>null</c> on a shape the parser /// Parse a Logix-symbolic tag reference. Returns <c>null</c> on a shape the parser
/// doesn't support — the driver surfaces that as a config-validation error rather than /// doesn't support — the driver surfaces that as a config-validation error rather than
@@ -91,8 +121,10 @@ public sealed record AbCipTagPath(
} }
var segments = new List<AbCipTagPathSegment>(parts.Count); var segments = new List<AbCipTagPathSegment>(parts.Count);
foreach (var part in parts) AbCipTagPathSlice? slice = null;
for (var partIdx = 0; partIdx < parts.Count; partIdx++)
{ {
var part = parts[partIdx];
var bracketIdx = part.IndexOf('['); var bracketIdx = part.IndexOf('[');
if (bracketIdx < 0) if (bracketIdx < 0)
{ {
@@ -104,6 +136,25 @@ public sealed record AbCipTagPath(
var name = part[..bracketIdx]; var name = part[..bracketIdx];
if (!IsValidIdent(name)) return null; if (!IsValidIdent(name)) return null;
var inner = part[(bracketIdx + 1)..^1]; var inner = part[(bracketIdx + 1)..^1];
// Slice syntax `[N..M]` — only allowed on the LAST segment, must not coexist with
// multi-dim subscripts, must not be combined with bit-index, and requires M >= N.
// Any other shape is rejected so callers see a config-validation error rather than
// the driver attempting a best-effort scalar read.
if (inner.Contains(".."))
{
if (partIdx != parts.Count - 1) return null; // slice + sub-element
if (bitIndex is not null) return null; // slice + bit index
if (inner.Contains(',')) return null; // slice cannot be multi-dim
var parts2 = inner.Split("..", 2, StringSplitOptions.None);
if (parts2.Length != 2) return null;
if (!int.TryParse(parts2[0], out var sliceStart) || sliceStart < 0) return null;
if (!int.TryParse(parts2[1], out var sliceEnd) || sliceEnd < sliceStart) return null;
slice = new AbCipTagPathSlice(sliceStart, sliceEnd);
segments.Add(new AbCipTagPathSegment(name, []));
continue;
}
var subs = new List<int>(); var subs = new List<int>();
foreach (var tok in inner.Split(',')) foreach (var tok in inner.Split(','))
{ {
@@ -115,7 +166,7 @@ public sealed record AbCipTagPath(
} }
if (segments.Count == 0) return null; if (segments.Count == 0) return null;
return new AbCipTagPath(programScope, segments, bitIndex); return new AbCipTagPath(programScope, segments, bitIndex, slice);
} }
private static bool IsValidIdent(string s) private static bool IsValidIdent(string s)
@@ -130,3 +181,15 @@ public sealed record AbCipTagPath(
/// <summary>One path segment: a member name plus any numeric subscripts.</summary> /// <summary>One path segment: a member name plus any numeric subscripts.</summary>
public sealed record AbCipTagPathSegment(string Name, IReadOnlyList<int> Subscripts); public sealed record AbCipTagPathSegment(string Name, IReadOnlyList<int> Subscripts);
/// <summary>
/// Inclusive-on-both-ends array slice carried on the trailing segment of an
/// <see cref="AbCipTagPath"/>. <c>Tag[0..15]</c> parses to <c>Start=0, End=15</c>; the
/// planner pairs this with libplctag's <c>ElementCount</c> attribute to issue a single
/// Rockwell array read covering <c>End - Start + 1</c> elements.
/// </summary>
public sealed record AbCipTagPathSlice(int Start, int End)
{
/// <summary>Total element count covered by the slice (inclusive both ends).</summary>
public int Count => End - Start + 1;
}
@@ -65,10 +65,20 @@ public interface IAbCipTagFactory
/// <param name="LibplctagPlcAttribute">libplctag <c>plc=...</c> attribute, per family profile.</param> /// <param name="LibplctagPlcAttribute">libplctag <c>plc=...</c> attribute, per family profile.</param>
/// <param name="TagName">Logix symbolic tag name as emitted by <see cref="AbCipTagPath.ToLibplctagName"/>.</param> /// <param name="TagName">Logix symbolic tag name as emitted by <see cref="AbCipTagPath.ToLibplctagName"/>.</param>
/// <param name="Timeout">libplctag operation timeout (applies to Initialize / Read / Write).</param> /// <param name="Timeout">libplctag operation timeout (applies to Initialize / Read / Write).</param>
/// <param name="StringMaxCapacity">Optional Logix STRINGnn DATA-array capacity (e.g. 20 / 40 / 80
/// for <c>STRING_20</c> / <c>STRING_40</c> / <c>STRING_80</c> UDTs). Threads through libplctag's
/// <c>str_max_capacity</c> attribute. <c>null</c> keeps libplctag's default 82-byte STRING
/// behaviour for back-compat.</param>
/// <param name="ElementCount">Optional libplctag <c>ElementCount</c> override — set to <c>N</c>
/// to issue a Rockwell array read covering <c>N</c> consecutive elements starting at the
/// subscripted index in <see cref="TagName"/>. Drives PR abcip-1.3 array-slice support;
/// <c>null</c> leaves libplctag's default scalar-element behaviour for back-compat.</param>
public sealed record AbCipTagCreateParams( public sealed record AbCipTagCreateParams(
string Gateway, string Gateway,
int Port, int Port,
string CipPath, string CipPath,
string LibplctagPlcAttribute, string LibplctagPlcAttribute,
string TagName, string TagName,
TimeSpan Timeout); TimeSpan Timeout,
int? StringMaxCapacity = null,
int? ElementCount = null);
@@ -24,6 +24,17 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
Name = p.TagName, Name = p.TagName,
Timeout = p.Timeout, Timeout = p.Timeout,
}; };
// PR abcip-1.2 — Logix STRINGnn variant decoding. When the caller pins a non-default
// DATA-array capacity (STRING_20 / STRING_40 / STRING_80 etc.), forward it to libplctag
// via the StringMaxCapacity attribute so GetString / SetString truncate at the right
// boundary. Null leaves libplctag at its default 82-byte STRING for back-compat.
if (p.StringMaxCapacity is int cap && cap > 0)
_tag.StringMaxCapacity = (uint)cap;
// PR abcip-1.3 — slice reads. Setting ElementCount tells libplctag to allocate a buffer
// covering N consecutive elements; the array-read planner pairs this with TagName=Tag[N]
// to issue one Rockwell array read for a [N..M] slice.
if (p.ElementCount is int n && n > 0)
_tag.ElementCount = n;
} }
public Task InitializeAsync(CancellationToken cancellationToken) => _tag.InitializeAsync(cancellationToken); public Task InitializeAsync(CancellationToken cancellationToken) => _tag.InitializeAsync(cancellationToken);
@@ -50,7 +61,7 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
AbCipDataType.Real => _tag.GetFloat32(offset), AbCipDataType.Real => _tag.GetFloat32(offset),
AbCipDataType.LReal => _tag.GetFloat64(offset), AbCipDataType.LReal => _tag.GetFloat64(offset),
AbCipDataType.String => _tag.GetString(offset), AbCipDataType.String => _tag.GetString(offset),
AbCipDataType.Dt => _tag.GetInt32(offset), AbCipDataType.Dt => _tag.GetInt64(offset),
AbCipDataType.Structure => null, AbCipDataType.Structure => null,
_ => null, _ => null,
}; };
@@ -105,7 +116,7 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
_tag.SetString(0, Convert.ToString(value) ?? string.Empty); _tag.SetString(0, Convert.ToString(value) ?? string.Empty);
break; break;
case AbCipDataType.Dt: case AbCipDataType.Dt:
_tag.SetInt32(0, Convert.ToInt32(value)); _tag.SetInt64(0, Convert.ToInt64(value));
break; break;
case AbCipDataType.Structure: case AbCipDataType.Structure:
throw new NotSupportedException("Whole-UDT writes land in PR 6."); throw new NotSupportedException("Whole-UDT writes land in PR 6.");
@@ -0,0 +1,110 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipArrayReadPlannerTests
{
private const string Device = "ab://10.0.0.5/1,0";
private static AbCipTagCreateParams BaseParams(string tagName) => new(
Gateway: "10.0.0.5",
Port: 44818,
CipPath: "1,0",
LibplctagPlcAttribute: "controllogix",
TagName: tagName,
Timeout: TimeSpan.FromSeconds(5));
[Fact]
public void TryBuild_emits_single_tag_create_with_element_count()
{
var def = new AbCipTagDefinition("DataSlice", Device, "Data[0..15]", AbCipDataType.DInt);
var parsed = AbCipTagPath.TryParse(def.TagPath)!;
var plan = AbCipArrayReadPlanner.TryBuild(def, parsed, BaseParams("Data[0..15]"));
plan.ShouldNotBeNull();
plan.ElementType.ShouldBe(AbCipDataType.DInt);
plan.Stride.ShouldBe(4);
plan.Slice.Count.ShouldBe(16);
plan.CreateParams.ElementCount.ShouldBe(16);
// Anchored at the slice start; libplctag reads N consecutive elements from there.
plan.CreateParams.TagName.ShouldBe("Data[0]");
}
[Fact]
public void TryBuild_returns_null_when_path_has_no_slice()
{
var def = new AbCipTagDefinition("Plain", Device, "Data[3]", AbCipDataType.DInt);
var parsed = AbCipTagPath.TryParse(def.TagPath)!;
AbCipArrayReadPlanner.TryBuild(def, parsed, BaseParams("Data[3]")).ShouldBeNull();
}
[Theory]
[InlineData(AbCipDataType.Bool)]
[InlineData(AbCipDataType.String)]
[InlineData(AbCipDataType.Structure)]
public void TryBuild_returns_null_for_unsupported_element_types(AbCipDataType type)
{
var def = new AbCipTagDefinition("Slice", Device, "Data[0..3]", type);
var parsed = AbCipTagPath.TryParse(def.TagPath)!;
AbCipArrayReadPlanner.TryBuild(def, parsed, BaseParams("Data[0..3]")).ShouldBeNull();
}
[Theory]
[InlineData(AbCipDataType.SInt, 1)]
[InlineData(AbCipDataType.Int, 2)]
[InlineData(AbCipDataType.DInt, 4)]
[InlineData(AbCipDataType.Real, 4)]
[InlineData(AbCipDataType.LInt, 8)]
[InlineData(AbCipDataType.LReal, 8)]
public void TryBuild_uses_natural_stride_per_element_type(AbCipDataType type, int expectedStride)
{
var def = new AbCipTagDefinition("Slice", Device, "Data[0..3]", type);
var parsed = AbCipTagPath.TryParse(def.TagPath)!;
var plan = AbCipArrayReadPlanner.TryBuild(def, parsed, BaseParams("Data[0..3]"))!;
plan.Stride.ShouldBe(expectedStride);
}
[Fact]
public void Decode_walks_buffer_at_element_stride()
{
var def = new AbCipTagDefinition("DataSlice", Device, "Data[0..3]", AbCipDataType.DInt);
var parsed = AbCipTagPath.TryParse(def.TagPath)!;
var plan = AbCipArrayReadPlanner.TryBuild(def, parsed, BaseParams("Data[0..3]"))!;
var fake = new FakeAbCipTag(plan.CreateParams);
// Stride == 4 for DInt, so offsets 0/4/8/12 hold the four element values.
fake.ValuesByOffset[0] = 100;
fake.ValuesByOffset[4] = 200;
fake.ValuesByOffset[8] = 300;
fake.ValuesByOffset[12] = 400;
var decoded = AbCipArrayReadPlanner.Decode(plan, fake);
decoded.Length.ShouldBe(4);
decoded.ShouldBe(new object?[] { 100, 200, 300, 400 });
}
[Fact]
public void Decode_preserves_slice_count_for_real_arrays()
{
var def = new AbCipTagDefinition("FloatSlice", Device, "Floats[2..5]", AbCipDataType.Real);
var parsed = AbCipTagPath.TryParse(def.TagPath)!;
var plan = AbCipArrayReadPlanner.TryBuild(def, parsed, BaseParams("Floats[2]"))!;
var fake = new FakeAbCipTag(plan.CreateParams);
fake.ValuesByOffset[0] = 1.5f;
fake.ValuesByOffset[4] = 2.5f;
fake.ValuesByOffset[8] = 3.5f;
fake.ValuesByOffset[12] = 4.5f;
var decoded = AbCipArrayReadPlanner.Decode(plan, fake);
decoded.ShouldBe(new object?[] { 1.5f, 2.5f, 3.5f, 4.5f });
}
}
@@ -165,6 +165,55 @@ public sealed class AbCipDriverReadTests
p.TagName.ShouldBe("Program:P.Counter"); p.TagName.ShouldBe("Program:P.Counter");
} }
[Fact]
public async Task Slice_tag_reads_one_array_and_decodes_n_elements()
{
// PR abcip-1.3 — `Data[0..3]` slice routes through AbCipArrayReadPlanner: one libplctag
// tag-create at TagName="Data[0]" with ElementCount=4, single PLC read, contiguous
// buffer decoded at element stride into one snapshot whose Value is an object?[].
var (drv, factory) = NewDriver(
new AbCipTagDefinition("DataSlice", "ab://10.0.0.5/1,0", "Data[0..3]", AbCipDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p =>
{
var t = new FakeAbCipTag(p);
t.ValuesByOffset[0] = 10;
t.ValuesByOffset[4] = 20;
t.ValuesByOffset[8] = 30;
t.ValuesByOffset[12] = 40;
return t;
};
var snapshots = await drv.ReadAsync(["DataSlice"], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
var values = snapshots.Single().Value.ShouldBeOfType<object?[]>();
values.ShouldBe(new object?[] { 10, 20, 30, 40 });
// Exactly ONE libplctag tag was created — anchored at the slice start with
// ElementCount=4. Without the planner this would have been four scalar reads.
factory.Tags.Count.ShouldBe(1);
factory.Tags.ShouldContainKey("Data[0]");
factory.Tags["Data[0]"].CreationParams.ElementCount.ShouldBe(4);
factory.Tags["Data[0]"].ReadCount.ShouldBe(1);
}
[Fact]
public async Task Slice_tag_with_unsupported_element_type_returns_BadNotSupported()
{
// BOOL slices can't be laid out from the declaration alone (Logix packs BOOLs into a
// hidden host byte). The planner refuses; the driver surfaces BadNotSupported instead
// of attempting a best-effort decode.
var (drv, _) = NewDriver(
new AbCipTagDefinition("BoolSlice", "ab://10.0.0.5/1,0", "Flags[0..7]", AbCipDataType.Bool));
await drv.InitializeAsync("{}", CancellationToken.None);
var snapshots = await drv.ReadAsync(["BoolSlice"], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported);
snapshots.Single().Value.ShouldBeNull();
}
[Fact] [Fact]
public async Task Cancellation_propagates_from_read() public async Task Cancellation_propagates_from_read()
{ {
@@ -211,4 +260,79 @@ public sealed class AbCipDriverReadTests
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError); snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError);
factory.Tags["Nope"].Disposed.ShouldBeTrue(); factory.Tags["Nope"].Disposed.ShouldBeTrue();
} }
// PR abcip-1.2 — STRINGnn variant decoding. Threading <see cref="AbCipTagDefinition.StringLength"/>
// through libplctag's StringMaxCapacity attribute lets STRING_20 / STRING_40 / STRING_80 UDTs
// decode against the right DATA-array size; null preserves the default 82-byte STRING.
[Fact]
public async Task StringLength_threads_into_TagCreateParams_StringMaxCapacity()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Banner", "ab://10.0.0.5/1,0", "Banner", AbCipDataType.String,
StringLength: 40));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbCipTag(p) { Value = "hello" };
await drv.ReadAsync(["Banner"], CancellationToken.None);
factory.Tags["Banner"].CreationParams.StringMaxCapacity.ShouldBe(40);
}
[Fact]
public async Task StringLength_null_leaves_StringMaxCapacity_null_for_back_compat()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("LegacyStr", "ab://10.0.0.5/1,0", "LegacyStr", AbCipDataType.String));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbCipTag(p) { Value = "world" };
await drv.ReadAsync(["LegacyStr"], CancellationToken.None);
factory.Tags["LegacyStr"].CreationParams.StringMaxCapacity.ShouldBeNull();
}
[Fact]
public async Task StringLength_ignored_for_non_String_data_types()
{
// StringLength on a DINT-typed tag must not flow into StringMaxCapacity — libplctag would
// otherwise re-shape the buffer and corrupt the read. EnsureTagRuntimeAsync gates on the
// declared DataType.
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt,
StringLength: 80));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbCipTag(p) { Value = 7 };
await drv.ReadAsync(["Speed"], CancellationToken.None);
factory.Tags["Speed"].CreationParams.StringMaxCapacity.ShouldBeNull();
}
[Fact]
public async Task UDT_member_StringLength_threads_through_to_member_runtime()
{
// STRINGnn members of a UDT — declaration-driven fan-out copies StringLength from
// AbCipStructureMember onto the synthesised member AbCipTagDefinition; the per-member
// runtime then receives the right StringMaxCapacity.
var udt = new AbCipTagDefinition(
Name: "Recipe",
DeviceHostAddress: "ab://10.0.0.5/1,0",
TagPath: "Recipe",
DataType: AbCipDataType.Structure,
Members: [
new AbCipStructureMember("Name", AbCipDataType.String, StringLength: 20),
new AbCipStructureMember("Description", AbCipDataType.String, StringLength: 80),
new AbCipStructureMember("Code", AbCipDataType.DInt),
]);
var (drv, factory) = NewDriver(udt);
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbCipTag(p) { Value = "x" };
await drv.ReadAsync(["Recipe.Name", "Recipe.Description", "Recipe.Code"], CancellationToken.None);
factory.Tags["Recipe.Name"].CreationParams.StringMaxCapacity.ShouldBe(20);
factory.Tags["Recipe.Description"].CreationParams.StringMaxCapacity.ShouldBe(80);
factory.Tags["Recipe.Code"].CreationParams.StringMaxCapacity.ShouldBeNull();
}
} }
@@ -124,8 +124,11 @@ public sealed class AbCipDriverTests
{ {
AbCipDataType.Bool.ToDriverDataType().ShouldBe(DriverDataType.Boolean); AbCipDataType.Bool.ToDriverDataType().ShouldBe(DriverDataType.Boolean);
AbCipDataType.DInt.ToDriverDataType().ShouldBe(DriverDataType.Int32); AbCipDataType.DInt.ToDriverDataType().ShouldBe(DriverDataType.Int32);
AbCipDataType.LInt.ToDriverDataType().ShouldBe(DriverDataType.Int64);
AbCipDataType.ULInt.ToDriverDataType().ShouldBe(DriverDataType.UInt64);
AbCipDataType.Real.ToDriverDataType().ShouldBe(DriverDataType.Float32); AbCipDataType.Real.ToDriverDataType().ShouldBe(DriverDataType.Float32);
AbCipDataType.LReal.ToDriverDataType().ShouldBe(DriverDataType.Float64); AbCipDataType.LReal.ToDriverDataType().ShouldBe(DriverDataType.Float64);
AbCipDataType.String.ToDriverDataType().ShouldBe(DriverDataType.String); AbCipDataType.String.ToDriverDataType().ShouldBe(DriverDataType.String);
AbCipDataType.Dt.ToDriverDataType().ShouldBe(DriverDataType.Int64);
} }
} }
@@ -123,6 +123,61 @@ public sealed class AbCipTagPathTests
AbCipTagPath.TryParse("_private_tag")!.Segments.Single().Name.ShouldBe("_private_tag"); AbCipTagPath.TryParse("_private_tag")!.Segments.Single().Name.ShouldBe("_private_tag");
} }
[Fact]
public void Slice_basic_inclusive_range()
{
var p = AbCipTagPath.TryParse("Data[0..15]");
p.ShouldNotBeNull();
p.Slice.ShouldNotBeNull();
p.Slice!.Start.ShouldBe(0);
p.Slice.End.ShouldBe(15);
p.Slice.Count.ShouldBe(16);
p.BitIndex.ShouldBeNull();
p.Segments.Single().Name.ShouldBe("Data");
p.Segments.Single().Subscripts.ShouldBeEmpty();
p.ToLibplctagName().ShouldBe("Data[0..15]");
// Slice array name omits the `..End` so libplctag sees an anchored read at the start
// index; pair with ElementCount to cover the whole range.
p.ToLibplctagSliceArrayName().ShouldBe("Data[0]");
}
[Fact]
public void Slice_with_program_scope_and_member_chain()
{
var p = AbCipTagPath.TryParse("Program:MainProgram.Motors.Data[3..7]");
p.ShouldNotBeNull();
p.ProgramScope.ShouldBe("MainProgram");
p.Segments.Select(s => s.Name).ShouldBe(["Motors", "Data"]);
p.Slice!.Start.ShouldBe(3);
p.Slice.End.ShouldBe(7);
p.ToLibplctagName().ShouldBe("Program:MainProgram.Motors.Data[3..7]");
p.ToLibplctagSliceArrayName().ShouldBe("Program:MainProgram.Motors.Data[3]");
}
[Fact]
public void Slice_zero_length_single_element_allowed()
{
// [5..5] is a one-element slice — degenerate but legal (a single read of one element).
var p = AbCipTagPath.TryParse("Data[5..5]");
p.ShouldNotBeNull();
p.Slice!.Count.ShouldBe(1);
}
[Theory]
[InlineData("Data[5..3]")] // M < N
[InlineData("Data[-1..5]")] // negative start
[InlineData("Data[0..15].Member")] // slice + sub-element
[InlineData("Data[0..15].3")] // slice + bit index
[InlineData("Data[0..15,1]")] // slice cannot be multi-dim
[InlineData("Data[0..15,2..3]")] // multi-dim slice not supported
[InlineData("Data[..5]")] // missing start
[InlineData("Data[5..]")] // missing end
[InlineData("Data[a..5]")] // non-numeric start
public void Invalid_slice_shapes_return_null(string input)
{
AbCipTagPath.TryParse(input).ShouldBeNull();
}
[Fact] [Fact]
public void ToLibplctagName_recomposes_round_trip() public void ToLibplctagName_recomposes_round_trip()
{ {