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();
+ }
}