From d78a471e90b723f317249f30808cfdf2f78d8b44 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 25 Apr 2026 12:53:20 -0400 Subject: [PATCH] =?UTF-8?q?Auto:=20abcip-1.2=20=E2=80=94=20STRINGnn=20vari?= =?UTF-8?q?ant=20decoding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../AbCipDriver.cs | 6 +- .../AbCipDriverOptions.cs | 13 +++- .../IAbCipTagRuntime.cs | 7 +- .../LibplctagTagRuntime.cs | 6 ++ .../AbCipDriverReadTests.cs | 75 +++++++++++++++++++ 5 files changed, 102 insertions(+), 5 deletions(-) diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs index b390164..3ac96b7 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -134,7 +134,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, TagPath: $"{tag.TagPath}.{member.Name}", DataType: member.DataType, Writable: member.Writable, - WriteIdempotent: member.WriteIdempotent); + WriteIdempotent: member.WriteIdempotent, + StringLength: member.StringLength); _tagsByName[memberTag.Name] = memberTag; } } @@ -633,7 +634,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, CipPath: device.ParsedAddress.CipPath, LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute, TagName: parsed.ToLibplctagName(), - Timeout: _options.Timeout)); + Timeout: _options.Timeout, + StringMaxCapacity: def.DataType == AbCipDataType.String ? def.StringLength : null)); try { await runtime.InitializeAsync(ct).ConfigureAwait(false); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs index f251c78..8c2c8d7 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs @@ -92,6 +92,13 @@ 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. +/// Capacity of the DATA character array on a Logix STRING / STRINGnn +/// UDT — 82 for the stock STRING, 20/40/80/etc for user-defined STRING_20, +/// STRING_40, STRING_80 variants. Threads through libplctag's +/// str_max_capacity attribute so the wrapper allocates the correct backing buffer +/// and GetString / SetString truncate at the right boundary. null +/// keeps libplctag's default 82-byte STRING behaviour for back-compat. Ignored for +/// non- types. public sealed record AbCipTagDefinition( string Name, string DeviceHostAddress, @@ -100,7 +107,8 @@ public sealed record AbCipTagDefinition( bool Writable = true, bool WriteIdempotent = false, IReadOnlyList? Members = null, - bool SafetyTag = false); + bool SafetyTag = false, + int? StringLength = null); /// /// One declared member of a UDT tag. Name is the member identifier on the PLC (e.g. Speed, @@ -112,7 +120,8 @@ public sealed record AbCipStructureMember( string Name, AbCipDataType DataType, bool Writable = true, - bool WriteIdempotent = false); + bool WriteIdempotent = false, + int? StringLength = null); /// Which AB PLC family the device is — selects the profile applied to connection params. public enum AbCipPlcFamily diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs index 16c9c90..d6b496e 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs @@ -65,10 +65,15 @@ public interface IAbCipTagFactory /// libplctag plc=... attribute, per family profile. /// Logix symbolic tag name as emitted by . /// libplctag operation timeout (applies to Initialize / Read / Write). +/// Optional Logix STRINGnn DATA-array capacity (e.g. 20 / 40 / 80 +/// for STRING_20 / STRING_40 / STRING_80 UDTs). Threads through libplctag's +/// str_max_capacity attribute. null keeps libplctag's default 82-byte STRING +/// behaviour for back-compat. public sealed record AbCipTagCreateParams( string Gateway, int Port, string CipPath, string LibplctagPlcAttribute, string TagName, - TimeSpan Timeout); + TimeSpan Timeout, + int? StringMaxCapacity = null); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs index 0b0d478..5389e6b 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs @@ -24,6 +24,12 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime Name = p.TagName, 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); diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverReadTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverReadTests.cs index dc63052..91369bc 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverReadTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverReadTests.cs @@ -211,4 +211,79 @@ public sealed class AbCipDriverReadTests snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError); factory.Tags["Nope"].Disposed.ShouldBeTrue(); } + + // PR abcip-1.2 — STRINGnn variant decoding. Threading + // 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(); + } }