Compare commits

...

4 Commits

Author SHA1 Message Date
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
7 changed files with 110 additions and 9 deletions
@@ -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;
} }
} }
@@ -633,7 +634,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
@@ -65,10 +65,15 @@ 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>
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);
@@ -24,6 +24,12 @@ 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;
} }
public Task InitializeAsync(CancellationToken cancellationToken) => _tag.InitializeAsync(cancellationToken); public Task InitializeAsync(CancellationToken cancellationToken) => _tag.InitializeAsync(cancellationToken);
@@ -50,7 +56,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 +111,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.");
@@ -211,4 +211,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);
} }
} }