diff --git a/docs/Driver.AbCip.Cli.md b/docs/Driver.AbCip.Cli.md
index 8140aeb..01633c5 100644
--- a/docs/Driver.AbCip.Cli.md
+++ b/docs/Driver.AbCip.Cli.md
@@ -81,3 +81,25 @@ otopcua-abcip-cli subscribe -g ab://10.0.0.5/1,0 -t Motor01_Speed --type Real -i
- **"Is this GuardLogix safety tag writable from non-safety?"** → `write` and
read the status code — safety tags surface `BadNotWritable` / CIP errors,
non-safety tags surface `Good`.
+
+## Connection Size
+
+PR abcip-3.1 introduced a per-device `ConnectionSize` override on the driver
+side (`AbCipDeviceOptions.ConnectionSize`, range `500..4002`). The CLI does
+not expose a flag for it — every CLI invocation uses the family-default
+Connection Size (4002 / 504 / 488 depending on `--family`). When a Forward
+Open is rejected with a CIP error like `0x01/0x113` ("connection request
+size invalid"), the symptom is almost always a **mismatch between the chosen
+family default and the controller firmware**:
+
+- **v19-and-earlier ControlLogix** caps at 504 — pick `--family CompactLogix`
+ on the CLI to fall back to that narrower default.
+- **5069-L1/L2/L3 CompactLogix** narrow-buffer parts also cap at 504, which
+ is the family default already.
+- **FW20+ ControlLogix** accepts the full 4002.
+
+For the warning *"AbCip device 'X' family 'Y' uses a narrow-buffer profile
+(default ConnectionSize Z); the configured ConnectionSize N exceeds the
+511-byte legacy-firmware cap..."* see
+[`docs/drivers/AbCip-Performance.md`](drivers/AbCip-Performance.md) — that
+warning is fired by the driver host, not the CLI.
diff --git a/docs/drivers/AbCip-Performance.md b/docs/drivers/AbCip-Performance.md
new file mode 100644
index 0000000..d432f7a
--- /dev/null
+++ b/docs/drivers/AbCip-Performance.md
@@ -0,0 +1,153 @@
+# AB CIP — Performance knobs
+
+Phase 3 of the AB CIP driver plan introduces a small set of operator-tunable
+performance knobs that change how the driver talks to the controller without
+altering the address space or per-tag semantics. They consolidate decisions
+that Kepware exposes as a slider / advanced page so deployments running into
+high-latency PLCs, narrow-CPU CompactLogix parts, or legacy ControlLogix
+firmware have an explicit lever to pull.
+
+This document is the home for those knobs as PRs land. PR abcip-3.1 ships the
+first knob: per-device **CIP Connection Size**.
+
+## Connection Size
+
+### What it is
+
+CIP Connection Size — the byte ceiling on a single Forward Open response
+fragment, set during the EtherNet/IP Forward Open handshake. Larger
+connection sizes pack more tags into a single CIP RTT (higher request-packing
+density, fewer round-trips for the same scan list); smaller connection sizes
+stay compatible with legacy or narrow-buffer firmware that rejects oversized
+Forward Open requests.
+
+### Family defaults
+
+The driver picks a Connection Size from the per-family profile when the
+device-level override is unset:
+
+| Family | Default | Rationale |
+|---|---:|---|
+| `ControlLogix` | `4002` | Large Forward Open — FW20+ |
+| `GuardLogix` | `4002` | Same wire protocol as ControlLogix |
+| `CompactLogix` | `504` | 5069-L1/L2/L3 narrow-buffer parts (5370 family) |
+| `Micro800` | `488` | Hard cap on Micro800 firmware |
+
+These map straight to libplctag's `connection_size` attribute and match the
+defaults Kepware uses out of the box for the same families.
+
+### Override knob
+
+`AbCipDeviceOptions.ConnectionSize` (`int?`, default `null`) overrides the
+family default for one device. Bind it through driver config JSON:
+
+```json
+{
+ "Devices": [
+ {
+ "HostAddress": "ab://10.0.0.5/1,0",
+ "PlcFamily": "ControlLogix",
+ "ConnectionSize": 504
+ }
+ ]
+}
+```
+
+The override threads through every libplctag handle the driver creates for
+that device — read tags, write tags, probe tags, UDT-template reads, the
+`@tags` walker, and BOOL-in-DINT parent runtimes. There is no per-tag
+override; one Connection Size applies to the whole controller (matches CIP
+session semantics).
+
+### Valid range
+
+`[500..4002]` bytes. This matches the slider Kepware exposes for the same
+family. Values outside the range fail driver `InitializeAsync` with an
+`InvalidOperationException` — there's no silent clamp; misconfigured devices
+fail loudly so operators see the problem at deploy time.
+
+| Value | Behaviour |
+|---|---|
+| `null` | Use family default (4002 / 504 / 488) |
+| `499` or below | Driver init fault — out-of-range |
+| `500..4002` | Threaded through to libplctag |
+| `4003` or above | Driver init fault — out-of-range |
+
+### Legacy-firmware caveat
+
+ControlLogix firmware **v19 and earlier** caps the CIP buffer at **504
+bytes** — Connection Sizes above that cause the controller to reject the
+Forward Open with CIP error 0x01/0x113. The 5069-L1/L2/L3 CompactLogix narrow
+parts are subject to the same cap.
+
+The driver emits a warning via `AbCipDriverOptions.OnWarning` when the
+configured Connection Size **exceeds 511** *and* the device's family profile
+default is also at-or-below the legacy cap (i.e. CompactLogix with default
+504, or Micro800 with default 488). Production hosting should wire
+`OnWarning` to the application logger; the unit tests (`AbCipConnectionSizeTests`)
+collect into a list to assert which warnings fired.
+
+The warning fires once per device at `InitializeAsync`. It does not block
+initialisation — operators may need the override anyway when running newer
+CompactLogix firmware that does support the larger Forward Open. The
+controller will reject the connection at runtime if it can't honour the size,
+and that surfaces through the standard `IHostConnectivityProbe` channel.
+
+### Performance trade-off
+
+| Larger Connection Size | Smaller Connection Size |
+|---|---|
+| More tags per CIP RTT — higher throughput | Compatible with legacy / narrow firmware |
+| Bigger buffers held by libplctag native (RSS impact) | Lower memory footprint |
+| Forward Open rejected on FW19- ControlLogix | Always works (assuming ≥500) |
+| Required for high-density scan lists | Forces more round-trips — higher latency |
+
+For most FW20+ ControlLogix shops, the default `4002` is correct and the
+override is unnecessary. The override is mainly useful when:
+
+1. **Migrating off Kepware** with a controller-specific slider value already
+ tuned in production — set Connection Size to match.
+2. **Mixed-firmware fleets** where some controllers are still on FW19 — set
+ the legacy controllers explicitly to `504`.
+3. **CompactLogix L1/L2/L3** running newer firmware that supports a larger
+ Forward Open than the family-default 504 — bump the override up.
+4. **Micro800** never goes above `488`; the override is for documentation /
+ discoverability rather than capability change.
+
+### libplctag wrapper limitation
+
+The libplctag .NET wrapper (1.5.x) does not expose `connection_size` as a
+public `Tag` property. The driver propagates the value via reflection on the
+wrapper's internal `NativeTagWrapper.SetIntAttribute("connection_size", N)`
+after `InitializeAsync` — equivalent to libplctag's
+`plc_tag_set_int_attribute`. Because libplctag native parses
+`connection_size` only at create time, this is **best-effort** until either:
+
+- the libplctag .NET wrapper exposes `ConnectionSize` directly (planned in
+ the upstream backlog), in which case the reflection no-ops cleanly, or
+- libplctag native gains post-create hot-update for `connection_size`, in
+ which case the call lands as intended.
+
+In the meantime the value is correctly stored on `DeviceState.ConnectionSize`
++ surfaces in every `AbCipTagCreateParams` the driver builds, so the override
+is observable end-to-end through the public driver surface and unit tests
+even if the underlying wrapper isn't yet honouring it on the wire.
+
+Operators who need *guaranteed* Connection Size enforcement against FW19
+controllers today can pin `libplctag` to a wrapper version that exposes
+`ConnectionSize` once one is available, or run a libplctag native build
+patched for runtime updates. Both paths are tracked in the AB CIP plan.
+
+### See also
+
+- [`docs/Driver.AbCip.Cli.md`](../Driver.AbCip.Cli.md) — AB CIP CLI uses the
+ family default ConnectionSize on each invocation; per-device overrides only
+ apply through the driver's device-config JSON, not the CLI's command-line.
+- [`docs/drivers/AbServer-Test-Fixture.md`](AbServer-Test-Fixture.md) §5 —
+ ab_server simulator does not enforce the narrow CompactLogix cap, so
+ Connection Size correctness is verified by unit tests + Emulate-rig live
+ smokes only.
+- [`PlcFamilies/AbCipPlcFamilyProfile.cs`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/PlcFamilies/AbCipPlcFamilyProfile.cs) —
+ per-family default values.
+- [`AbCipConnectionSize`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipConnectionSize.cs) —
+ range bounds + legacy-firmware threshold constants.
diff --git a/docs/drivers/AbServer-Test-Fixture.md b/docs/drivers/AbServer-Test-Fixture.md
index a656d3c..5632330 100644
--- a/docs/drivers/AbServer-Test-Fixture.md
+++ b/docs/drivers/AbServer-Test-Fixture.md
@@ -96,6 +96,15 @@ value per PR 10, but `ab_server` accepts whatever the client asks for — the
cap's correctness is trusted from its unit test, never stressed against a
simulator that rejects oversized requests.
+PR abcip-3.1 layers the **per-device `ConnectionSize` override** on top
+(`AbCipDeviceOptions.ConnectionSize`, range `[500..4002]`, see
+[`AbCip-Performance.md`](AbCip-Performance.md)). Same gap — `ab_server`
+happily honours an oversized override against the CompactLogix profile, so
+the legacy-firmware warning + Forward Open rejection that real 5069-L1/L2/L3
+parts emit are unit-tested only. Live coverage stays Emulate / rig-only
+(connect against a real CompactLogix L2 with `ConnectionSize=1500` to
+confirm the Forward Open fails with CIP error 0x01/0x113).
+
### 6. BOOL-within-DINT read-modify-write (#181)
The `AbCipDriver.WriteBitInDIntAsync` RMW path + its per-parent `SemaphoreSlim`
diff --git a/scripts/smoke/seed-abcip-smoke.sql b/scripts/smoke/seed-abcip-smoke.sql
index 14028cb..d513f21 100644
--- a/scripts/smoke/seed-abcip-smoke.sql
+++ b/scripts/smoke/seed-abcip-smoke.sql
@@ -80,6 +80,11 @@ VALUES (@Gen, @EqId, @EqUuid, @DrvId, @LineId, 'ab-sim', 'abcip-001', 1);
-- AB CIP DriverInstance — single ControlLogix device at the ab_server fixture
-- gateway. DriverConfig shape mirrors AbCipDriverConfigDto.
+--
+-- The second device entry (CompactLogix L2 example, commented out) demonstrates
+-- the PR abcip-3.1 ConnectionSize override knob. Uncomment + point at a real
+-- 5069-L2 to verify the narrow-buffer Forward Open path; ab_server itself
+-- doesn't enforce the narrow cap (see docs/drivers/AbServer-Test-Fixture.md §5).
INSERT dbo.DriverInstance(GenerationId, DriverInstanceId, ClusterId, NamespaceId,
Name, DriverType, DriverConfig, Enabled)
VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'ab-server-smoke', 'AbCip', N'{
@@ -90,6 +95,14 @@ VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'ab-server-smoke', 'AbCip', N'{
"PlcFamily": "ControlLogix",
"DeviceName": "ab-server"
}
+ /*
+ , {
+ "HostAddress": "ab://10.0.0.7/1,0",
+ "PlcFamily": "CompactLogix",
+ "DeviceName": "compactlogix-l2-narrow",
+ "ConnectionSize": 504
+ }
+ */
],
"Probe": { "Enabled": true, "IntervalMs": 5000, "TimeoutMs": 2000 },
"Tags": [
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipConnectionSize.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipConnectionSize.cs
new file mode 100644
index 0000000..3c767d6
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipConnectionSize.cs
@@ -0,0 +1,29 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
+
+///
+/// PR abcip-3.1 — bounds + magic numbers for the per-device CIP ConnectionSize
+/// override. Pulled into a single place so config validation, the legacy-firmware warning,
+/// and the docs stay in sync.
+///
+public static class AbCipConnectionSize
+{
+ ///
+ /// Minimum supported CIP Forward Open buffer size, in bytes. Matches the lower bound of
+ /// Kepware's connection-size slider for ControlLogix drivers + the libplctag native
+ /// floor that still leaves headroom for the CIP MR header.
+ ///
+ public const int Min = 500;
+
+ ///
+ /// Maximum supported CIP Forward Open buffer size, in bytes. Matches the upper bound of
+ /// Kepware's slider + the Large Forward Open ceiling on FW20+ ControlLogix.
+ ///
+ public const int Max = 4002;
+
+ ///
+ /// Soft cap above which legacy ControlLogix firmware (v19 and earlier) rejects the
+ /// Forward Open. CompactLogix L1/L2/L3 narrow-cap parts (5069-L1/L2/L3) and Micro800
+ /// hard-cap below this too. Used as the threshold for the legacy-firmware warning.
+ ///
+ public const int LegacyFirmwareCap = 511;
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
index eb11b98..c1793fe 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
@@ -83,7 +83,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
CipPath: device.ParsedAddress.CipPath,
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
TagName: $"@udt/{templateInstanceId}",
- Timeout: _options.Timeout);
+ Timeout: _options.Timeout,
+ ConnectionSize: device.ConnectionSize);
try
{
@@ -121,6 +122,31 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
?? throw new InvalidOperationException(
$"AbCip device has invalid HostAddress '{device.HostAddress}' — expected 'ab://gateway[:port]/cip-path'.");
var profile = AbCipPlcFamilyProfile.ForFamily(device.PlcFamily);
+ // PR abcip-3.1 — validate the optional ConnectionSize override before stamping
+ // the device. The Kepware-supported range [500..4002] is what the libplctag
+ // ControlLogix driver supports; anything outside that fails the Forward Open at
+ // runtime, so we reject it loudly at config time instead.
+ if (device.ConnectionSize is int explicitSize)
+ {
+ if (explicitSize < AbCipConnectionSize.Min || explicitSize > AbCipConnectionSize.Max)
+ throw new InvalidOperationException(
+ $"AbCip device '{device.HostAddress}' has ConnectionSize {explicitSize} outside the supported range " +
+ $"[{AbCipConnectionSize.Min}..{AbCipConnectionSize.Max}].");
+ // Legacy-firmware warning: families whose profile default is 504 (CompactLogix
+ // narrow cap, also where v19-and-earlier ControlLogix lives) can't actually
+ // raise their CIP buffer above 511 bytes — the controller rejects the Forward
+ // Open. Ship the override anyway so newer firmware can use it, but flag the
+ // mismatch so operators see it in the warning sink.
+ if (explicitSize > AbCipConnectionSize.LegacyFirmwareCap
+ && profile.DefaultConnectionSize <= AbCipConnectionSize.LegacyFirmwareCap)
+ {
+ _options.OnWarning?.Invoke(
+ $"AbCip device '{device.HostAddress}' family '{device.PlcFamily}' uses a narrow-buffer profile " +
+ $"(default ConnectionSize {profile.DefaultConnectionSize}); the configured ConnectionSize {explicitSize} " +
+ $"exceeds the {AbCipConnectionSize.LegacyFirmwareCap}-byte legacy-firmware cap and will fail the " +
+ "Forward Open on v19-and-earlier ControlLogix or 5069-L1/L2/L3 CompactLogix firmware.");
+ }
+ }
_devices[device.HostAddress] = new DeviceState(addr, device, profile);
}
// Pre-declared tags first; L5K imports fill in only the names not already covered
@@ -356,7 +382,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
CipPath: state.ParsedAddress.CipPath,
LibplctagPlcAttribute: state.Profile.LibplctagPlcAttribute,
TagName: _options.Probe.ProbeTagPath!,
- Timeout: _options.Probe.Timeout);
+ Timeout: _options.Probe.Timeout,
+ ConnectionSize: state.ConnectionSize);
IAbCipTagRuntime? probeRuntime = null;
while (!ct.IsCancellationRequested)
@@ -533,7 +560,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
CipPath: device.ParsedAddress.CipPath,
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
TagName: parsedPath.ToLibplctagName(),
- Timeout: _options.Timeout);
+ Timeout: _options.Timeout,
+ ConnectionSize: device.ConnectionSize);
var plan = AbCipArrayReadPlanner.TryBuild(def, parsedPath, baseParams);
if (plan is null)
@@ -892,7 +920,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
CipPath: device.ParsedAddress.CipPath,
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
TagName: parentTagName,
- Timeout: _options.Timeout));
+ Timeout: _options.Timeout,
+ ConnectionSize: device.ConnectionSize));
try
{
await runtime.InitializeAsync(ct).ConfigureAwait(false);
@@ -927,7 +956,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
TagName: parsed.ToLibplctagName(),
Timeout: _options.Timeout,
- StringMaxCapacity: def.DataType == AbCipDataType.String ? def.StringLength : null));
+ StringMaxCapacity: def.DataType == AbCipDataType.String ? def.StringLength : null,
+ ConnectionSize: device.ConnectionSize));
try
{
await runtime.InitializeAsync(ct).ConfigureAwait(false);
@@ -1081,7 +1111,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
CipPath: state.ParsedAddress.CipPath,
LibplctagPlcAttribute: state.Profile.LibplctagPlcAttribute,
TagName: "@tags",
- Timeout: _options.Timeout);
+ Timeout: _options.Timeout,
+ ConnectionSize: state.ConnectionSize);
IAddressSpaceBuilder? discoveredFolder = null;
await foreach (var discovered in enumerator.EnumerateAsync(deviceParams, cancellationToken)
@@ -1152,6 +1183,16 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
public AbCipDeviceOptions Options { get; } = options;
public AbCipPlcFamilyProfile Profile { get; } = profile;
+ ///
+ /// PR abcip-3.1 — effective CIP connection size for this device. Per-device
+ /// override wins; otherwise the
+ /// family profile's
+ /// (4002 / 504 / 488 depending on family). Threaded through every
+ /// the driver builds so libplctag receives a
+ /// consistent buffer-size hint across read / write / probe / discovery handles.
+ ///
+ public int ConnectionSize { get; } = options.ConnectionSize ?? profile.DefaultConnectionSize;
+
public object ProbeLock { get; } = new();
public HostState HostState { get; set; } = HostState.Unknown;
public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow;
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs
index 501b51e..9e80fdd 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs
@@ -37,7 +37,8 @@ public static class AbCipDriverFactoryExtensions
$"AB CIP config for '{driverInstanceId}' has a device missing HostAddress"),
PlcFamily: ParseEnum(d.PlcFamily, "device", driverInstanceId, "PlcFamily",
fallback: AbCipPlcFamily.ControlLogix),
- DeviceName: d.DeviceName))]
+ DeviceName: d.DeviceName,
+ ConnectionSize: d.ConnectionSize))]
: [],
Tags = dto.Tags is { Count: > 0 }
? [.. dto.Tags.Select(t => BuildTag(t, driverInstanceId))]
@@ -119,6 +120,12 @@ public static class AbCipDriverFactoryExtensions
public string? HostAddress { get; init; }
public string? PlcFamily { get; init; }
public string? DeviceName { get; init; }
+
+ ///
+ /// PR abcip-3.1 — optional per-device CIP ConnectionSize override. Validated
+ /// against [500..4002] at .
+ ///
+ public int? ConnectionSize { get; init; }
}
internal sealed class AbCipTagDto
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs
index d76845b..3490fc2 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs
@@ -87,6 +87,14 @@ public sealed class AbCipDriverOptions
/// 1 second — matches typical SCADA alarm-refresh conventions.
///
public TimeSpan AlarmPollInterval { get; init; } = TimeSpan.FromSeconds(1);
+
+ ///
+ /// PR abcip-3.1 — optional sink for non-fatal driver warnings (legacy-firmware
+ /// ConnectionSize mis-match, etc.). Production hosting wires this to Serilog;
+ /// unit tests pin a list-collecting lambda to assert which warnings fired. null
+ /// swallows warnings — convenient for back-compat deployments that don't care.
+ ///
+ public Action? OnWarning { get; init; }
}
///
@@ -98,10 +106,19 @@ public sealed class AbCipDriverOptions
/// Which per-family profile to apply. Determines ConnectionSize,
/// request-packing support, unconnected-only hint, and other quirks.
/// Optional display label for Admin UI. Falls back to .
+/// PR abcip-3.1 — optional override for the family-default
+/// . Threads through to
+/// libplctag's connection_size attribute on the underlying tag handle so operators can
+/// dial the CIP Forward Open buffer down for legacy firmware (v19-and-earlier ControlLogix
+/// caps at 504) or up for high-throughput shops on FW20+. Validated against the Kepware
+/// supported range [500..4002] at InitializeAsync; out-of-range values fault the
+/// driver. null uses the family default — back-compat with deployments that haven't
+/// touched the knob.
public sealed record AbCipDeviceOptions(
string HostAddress,
AbCipPlcFamily PlcFamily = AbCipPlcFamily.ControlLogix,
- string? DeviceName = null);
+ string? DeviceName = null,
+ int? ConnectionSize = null);
///
/// One AB-backed OPC UA variable. Mirrors the ModbusTagDefinition shape.
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs
index c720b13..33b374b 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs
@@ -73,6 +73,13 @@ public interface IAbCipTagFactory
/// to issue a Rockwell array read covering N consecutive elements starting at the
/// subscripted index in . Drives PR abcip-1.3 array-slice support;
/// null leaves libplctag's default scalar-element behaviour for back-compat.
+/// PR abcip-3.1 — CIP Forward Open buffer size in bytes. Threads
+/// through to libplctag's connection_size attribute. The driver always supplies a
+/// value here — either the per-device
+/// override or the family profile's .
+/// Bigger packets fit more tags per RTT (higher throughput); smaller packets stay compatible
+/// with legacy firmware (v19-and-earlier ControlLogix caps at 504, Micro800 hard-caps at
+/// 488).
public sealed record AbCipTagCreateParams(
string Gateway,
int Port,
@@ -81,4 +88,5 @@ public sealed record AbCipTagCreateParams(
string TagName,
TimeSpan Timeout,
int? StringMaxCapacity = null,
- int? ElementCount = null);
+ int? ElementCount = null,
+ int ConnectionSize = 4002);
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs
index 7bf8015..1a51bc4 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs
@@ -1,3 +1,4 @@
+using System.Reflection;
using libplctag;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
@@ -12,6 +13,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
{
private readonly Tag _tag;
+ private readonly int _connectionSize;
public LibplctagTagRuntime(AbCipTagCreateParams p)
{
@@ -35,12 +37,55 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
// to issue one Rockwell array read for a [N..M] slice.
if (p.ElementCount is int n && n > 0)
_tag.ElementCount = n;
+ _connectionSize = p.ConnectionSize;
+ }
+
+ public async Task InitializeAsync(CancellationToken cancellationToken)
+ {
+ await _tag.InitializeAsync(cancellationToken).ConfigureAwait(false);
+ // PR abcip-3.1 — propagate the configured CIP connection size to the native libplctag
+ // handle. The 1.5.x C# wrapper does not expose connection_size as a public Tag
+ // property, so we reach into the internal NativeTagWrapper's
+ // SetIntAttribute (mirroring libplctag's plc_tag_set_int_attribute).
+ // libplctag native parses connection_size at create time, so this best-effort
+ // call lights up automatically when a future wrapper release exposes the attribute or
+ // when libplctag native gains post-create hot-update support — until then it falls back
+ // to the wrapper default. Failures (older / patched wrappers without the internal API)
+ // are intentionally swallowed so the driver keeps initialising.
+ TrySetConnectionSize(_tag, _connectionSize);
}
- public Task InitializeAsync(CancellationToken cancellationToken) => _tag.InitializeAsync(cancellationToken);
public Task ReadAsync(CancellationToken cancellationToken) => _tag.ReadAsync(cancellationToken);
public Task WriteAsync(CancellationToken cancellationToken) => _tag.WriteAsync(cancellationToken);
+ ///
+ /// Best-effort propagation of connection_size to libplctag native. Reflects into
+ /// the wrapper's internal NativeTagWrapper.SetIntAttribute(string, int); isolated
+ /// in a static helper so the lookup costs run once + the failure path is one line.
+ ///
+ private static void TrySetConnectionSize(Tag tag, int connectionSize)
+ {
+ try
+ {
+ var wrapperField = typeof(Tag).GetField("_tag", BindingFlags.NonPublic | BindingFlags.Instance);
+ var wrapper = wrapperField?.GetValue(tag);
+ if (wrapper is null) return;
+ var setInt = wrapper.GetType().GetMethod(
+ "SetIntAttribute",
+ BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance,
+ binder: null,
+ types: [typeof(string), typeof(int)],
+ modifiers: null);
+ setInt?.Invoke(wrapper, ["connection_size", connectionSize]);
+ }
+ catch
+ {
+ // Wrapper internals shifted (newer libplctag.NET) — drop quietly. Either the new
+ // wrapper exposes ConnectionSize directly (our reflection no-ops) or operators must
+ // upgrade to a known-good version per docs/drivers/AbCip-Performance.md.
+ }
+ }
+
public int GetStatus() => (int)_tag.GetStatus();
public object? DecodeValue(AbCipDataType type, int? bitIndex) => DecodeValueAt(type, 0, bitIndex);
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipConnectionSizeTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipConnectionSizeTests.cs
new file mode 100644
index 0000000..061b3cf
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipConnectionSizeTests.cs
@@ -0,0 +1,329 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
+using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
+
+///
+/// PR abcip-3.1 — coverage for the per-device CIP ConnectionSize override.
+/// Asserts (a) the value flows from into every
+/// the driver builds, (b) the family default kicks in
+/// when the override is unset, (c) values outside the Kepware-supported range are rejected
+/// at InitializeAsync, and (d) the legacy-firmware warning fires when a CompactLogix
+/// narrow-cap device is configured above 511 bytes.
+///
+[Trait("Category", "Unit")]
+public sealed class AbCipConnectionSizeTests
+{
+ // ---- options threading ----
+
+ [Fact]
+ public async Task Custom_ConnectionSize_flows_from_device_options_into_create_params()
+ {
+ var factory = new FakeAbCipTagFactory();
+ var drv = new AbCipDriver(new AbCipDriverOptions
+ {
+ Devices =
+ [
+ new AbCipDeviceOptions(
+ HostAddress: "ab://10.0.0.5/1,0",
+ PlcFamily: AbCipPlcFamily.ControlLogix,
+ ConnectionSize: 1500),
+ ],
+ Probe = new AbCipProbeOptions { Enabled = false },
+ Tags =
+ [
+ new AbCipTagDefinition(
+ Name: "Speed",
+ DeviceHostAddress: "ab://10.0.0.5/1,0",
+ TagPath: "Speed",
+ DataType: AbCipDataType.DInt),
+ ],
+ }, "drv-1", tagFactory: factory);
+
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ await drv.ReadAsync(["Speed"], CancellationToken.None);
+
+ factory.Tags["Speed"].CreationParams.ConnectionSize.ShouldBe(1500);
+ }
+
+ [Fact]
+ public async Task Unset_ConnectionSize_falls_back_to_ControlLogix_family_default()
+ {
+ var factory = new FakeAbCipTagFactory();
+ var drv = new AbCipDriver(new AbCipDriverOptions
+ {
+ Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", AbCipPlcFamily.ControlLogix)],
+ Probe = new AbCipProbeOptions { Enabled = false },
+ Tags =
+ [
+ new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt),
+ ],
+ }, "drv-1", tagFactory: factory);
+
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ await drv.ReadAsync(["Speed"], CancellationToken.None);
+
+ factory.Tags["Speed"].CreationParams.ConnectionSize
+ .ShouldBe(AbCipPlcFamilyProfile.ControlLogix.DefaultConnectionSize);
+ factory.Tags["Speed"].CreationParams.ConnectionSize.ShouldBe(4002);
+ }
+
+ [Fact]
+ public async Task Unset_ConnectionSize_falls_back_to_CompactLogix_family_default()
+ {
+ var factory = new FakeAbCipTagFactory();
+ var drv = new AbCipDriver(new AbCipDriverOptions
+ {
+ Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", AbCipPlcFamily.CompactLogix)],
+ Probe = new AbCipProbeOptions { Enabled = false },
+ Tags =
+ [
+ new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt),
+ ],
+ }, "drv-1", tagFactory: factory);
+
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ await drv.ReadAsync(["Speed"], CancellationToken.None);
+
+ factory.Tags["Speed"].CreationParams.ConnectionSize.ShouldBe(504);
+ }
+
+ [Fact]
+ public async Task Unset_ConnectionSize_falls_back_to_Micro800_family_default()
+ {
+ var factory = new FakeAbCipTagFactory();
+ var drv = new AbCipDriver(new AbCipDriverOptions
+ {
+ Devices = [new AbCipDeviceOptions("ab://10.0.0.6/", AbCipPlcFamily.Micro800)],
+ Probe = new AbCipProbeOptions { Enabled = false },
+ Tags =
+ [
+ new AbCipTagDefinition("Speed", "ab://10.0.0.6/", "Speed", AbCipDataType.DInt),
+ ],
+ }, "drv-1", tagFactory: factory);
+
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ await drv.ReadAsync(["Speed"], CancellationToken.None);
+
+ factory.Tags["Speed"].CreationParams.ConnectionSize.ShouldBe(488);
+ }
+
+ // ---- range validation ----
+
+ [Theory]
+ [InlineData(499)]
+ [InlineData(0)]
+ [InlineData(-1)]
+ [InlineData(4003)]
+ [InlineData(10000)]
+ public async Task Out_of_range_ConnectionSize_throws_at_InitializeAsync(int badSize)
+ {
+ var drv = new AbCipDriver(new AbCipDriverOptions
+ {
+ Devices =
+ [
+ new AbCipDeviceOptions(
+ HostAddress: "ab://10.0.0.5/1,0",
+ PlcFamily: AbCipPlcFamily.ControlLogix,
+ ConnectionSize: badSize),
+ ],
+ Probe = new AbCipProbeOptions { Enabled = false },
+ }, "drv-1");
+
+ var ex = await Should.ThrowAsync(
+ () => drv.InitializeAsync("{}", CancellationToken.None));
+ ex.Message.ShouldContain("ConnectionSize");
+ }
+
+ [Theory]
+ [InlineData(500)]
+ [InlineData(504)]
+ [InlineData(2000)]
+ [InlineData(4002)]
+ public async Task In_range_ConnectionSize_initialises_cleanly(int goodSize)
+ {
+ var drv = new AbCipDriver(new AbCipDriverOptions
+ {
+ Devices =
+ [
+ new AbCipDeviceOptions(
+ HostAddress: "ab://10.0.0.5/1,0",
+ PlcFamily: AbCipPlcFamily.ControlLogix,
+ ConnectionSize: goodSize),
+ ],
+ Probe = new AbCipProbeOptions { Enabled = false },
+ }, "drv-1");
+
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ drv.GetDeviceState("ab://10.0.0.5/1,0")!.ConnectionSize.ShouldBe(goodSize);
+ }
+
+ // ---- legacy-firmware warning ----
+
+ [Fact]
+ public async Task Oversized_ConnectionSize_on_CompactLogix_emits_legacy_warning()
+ {
+ var warnings = new List();
+ var drv = new AbCipDriver(new AbCipDriverOptions
+ {
+ Devices =
+ [
+ new AbCipDeviceOptions(
+ HostAddress: "ab://10.0.0.5/1,0",
+ PlcFamily: AbCipPlcFamily.CompactLogix,
+ ConnectionSize: 1500),
+ ],
+ Probe = new AbCipProbeOptions { Enabled = false },
+ OnWarning = warnings.Add,
+ }, "drv-1");
+
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ warnings.ShouldHaveSingleItem();
+ warnings[0].ShouldContain("CompactLogix");
+ warnings[0].ShouldContain("1500");
+ warnings[0].ShouldContain("Forward Open");
+ }
+
+ [Fact]
+ public async Task Within_legacy_cap_on_CompactLogix_does_not_warn()
+ {
+ var warnings = new List();
+ var drv = new AbCipDriver(new AbCipDriverOptions
+ {
+ Devices =
+ [
+ new AbCipDeviceOptions(
+ HostAddress: "ab://10.0.0.5/1,0",
+ PlcFamily: AbCipPlcFamily.CompactLogix,
+ ConnectionSize: 504),
+ ],
+ Probe = new AbCipProbeOptions { Enabled = false },
+ OnWarning = warnings.Add,
+ }, "drv-1");
+
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ warnings.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task Oversized_ConnectionSize_on_ControlLogix_does_not_warn()
+ {
+ // ControlLogix profile default is 4002 (Large Forward Open) — the warning is only
+ // meaningful when the family default is in the legacy-cap bucket. FW20+ ControlLogix
+ // happily accepts 1500-byte connections, so no warning fires.
+ var warnings = new List();
+ var drv = new AbCipDriver(new AbCipDriverOptions
+ {
+ Devices =
+ [
+ new AbCipDeviceOptions(
+ HostAddress: "ab://10.0.0.5/1,0",
+ PlcFamily: AbCipPlcFamily.ControlLogix,
+ ConnectionSize: 1500),
+ ],
+ Probe = new AbCipProbeOptions { Enabled = false },
+ OnWarning = warnings.Add,
+ }, "drv-1");
+
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ warnings.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task Oversized_ConnectionSize_on_Micro800_emits_legacy_warning()
+ {
+ // Micro800 default is 488 (well under the legacy cap), so any over-511 override
+ // triggers the same family-mismatch warning.
+ var warnings = new List();
+ var drv = new AbCipDriver(new AbCipDriverOptions
+ {
+ Devices =
+ [
+ new AbCipDeviceOptions(
+ HostAddress: "ab://10.0.0.6/",
+ PlcFamily: AbCipPlcFamily.Micro800,
+ ConnectionSize: 1000),
+ ],
+ Probe = new AbCipProbeOptions { Enabled = false },
+ OnWarning = warnings.Add,
+ }, "drv-1");
+
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ warnings.ShouldHaveSingleItem();
+ warnings[0].ShouldContain("Micro800");
+ }
+
+ // ---- DeviceState resolved ConnectionSize ----
+
+ [Fact]
+ public async Task DeviceState_ConnectionSize_reflects_override_when_set()
+ {
+ var drv = new AbCipDriver(new AbCipDriverOptions
+ {
+ Devices =
+ [
+ new AbCipDeviceOptions("ab://10.0.0.5/1,0", AbCipPlcFamily.ControlLogix, ConnectionSize: 2000),
+ ],
+ Probe = new AbCipProbeOptions { Enabled = false },
+ }, "drv-1");
+
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ drv.GetDeviceState("ab://10.0.0.5/1,0")!.ConnectionSize.ShouldBe(2000);
+ }
+
+ [Fact]
+ public async Task DeviceState_ConnectionSize_reflects_family_default_when_unset()
+ {
+ var drv = new AbCipDriver(new AbCipDriverOptions
+ {
+ Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", AbCipPlcFamily.CompactLogix)],
+ Probe = new AbCipProbeOptions { Enabled = false },
+ }, "drv-1");
+
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ drv.GetDeviceState("ab://10.0.0.5/1,0")!.ConnectionSize.ShouldBe(504);
+ }
+
+ // ---- AbCipConnectionSize constants ----
+
+ [Fact]
+ public void Constants_match_documented_Kepware_range()
+ {
+ AbCipConnectionSize.Min.ShouldBe(500);
+ AbCipConnectionSize.Max.ShouldBe(4002);
+ AbCipConnectionSize.LegacyFirmwareCap.ShouldBe(511);
+ }
+
+ // ---- DriverConfig DTO path (DriverFactoryRegistry-bound deployments) ----
+
+ [Fact]
+ public async Task Driver_factory_threads_ConnectionSize_through_config_json()
+ {
+ // The bootstrapper-driven path deserialises driver config from JSON in the central
+ // DB (sp_PublishGeneration → DriverInstance.DriverConfig). The DTO must surface
+ // ConnectionSize so production deployments don't lose the override at the wire.
+ var json = """
+ {
+ "Devices": [
+ {
+ "HostAddress": "ab://10.0.0.5/1,0",
+ "PlcFamily": "ControlLogix",
+ "ConnectionSize": 1500
+ }
+ ],
+ "Probe": { "Enabled": false }
+ }
+ """;
+ var drv = AbCipDriverFactoryExtensions.CreateInstance("drv-1", json);
+ // CreateInstance returns a fully-built driver; we kick InitializeAsync to surface the
+ // resolved DeviceState.ConnectionSize.
+ await drv.InitializeAsync(json, CancellationToken.None);
+ drv.GetDeviceState("ab://10.0.0.5/1,0")!.ConnectionSize.ShouldBe(1500);
+ }
+}