Auto: abcip-3.1 — configurable CIP connection size per device

Closes #235
This commit is contained in:
Joseph Doherty
2026-04-25 22:39:05 -04:00
parent 7cbddd4b4a
commit f6c26db609
11 changed files with 683 additions and 10 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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`

View File

@@ -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": [

View File

@@ -0,0 +1,29 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// PR abcip-3.1 — bounds + magic numbers for the per-device CIP <c>ConnectionSize</c>
/// override. Pulled into a single place so config validation, the legacy-firmware warning,
/// and the docs stay in sync.
/// </summary>
public static class AbCipConnectionSize
{
/// <summary>
/// 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.
/// </summary>
public const int Min = 500;
/// <summary>
/// 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.
/// </summary>
public const int Max = 4002;
/// <summary>
/// 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.
/// </summary>
public const int LegacyFirmwareCap = 511;
}

View File

@@ -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;
/// <summary>
/// PR abcip-3.1 — effective CIP connection size for this device. Per-device
/// <see cref="AbCipDeviceOptions.ConnectionSize"/> override wins; otherwise the
/// family profile's <see cref="AbCipPlcFamilyProfile.DefaultConnectionSize"/>
/// (4002 / 504 / 488 depending on family). Threaded through every
/// <see cref="AbCipTagCreateParams"/> the driver builds so libplctag receives a
/// consistent buffer-size hint across read / write / probe / discovery handles.
/// </summary>
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;

View File

@@ -37,7 +37,8 @@ public static class AbCipDriverFactoryExtensions
$"AB CIP config for '{driverInstanceId}' has a device missing HostAddress"),
PlcFamily: ParseEnum<AbCipPlcFamily>(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; }
/// <summary>
/// PR abcip-3.1 — optional per-device CIP <c>ConnectionSize</c> override. Validated
/// against <c>[500..4002]</c> at <see cref="AbCipDriver.InitializeAsync"/>.
/// </summary>
public int? ConnectionSize { get; init; }
}
internal sealed class AbCipTagDto

View File

@@ -87,6 +87,14 @@ public sealed class AbCipDriverOptions
/// 1 second — matches typical SCADA alarm-refresh conventions.
/// </summary>
public TimeSpan AlarmPollInterval { get; init; } = TimeSpan.FromSeconds(1);
/// <summary>
/// PR abcip-3.1 — optional sink for non-fatal driver warnings (legacy-firmware
/// <c>ConnectionSize</c> mis-match, etc.). Production hosting wires this to Serilog;
/// unit tests pin a list-collecting lambda to assert which warnings fired. <c>null</c>
/// swallows warnings — convenient for back-compat deployments that don't care.
/// </summary>
public Action<string>? OnWarning { get; init; }
}
/// <summary>
@@ -98,10 +106,19 @@ public sealed class AbCipDriverOptions
/// <param name="PlcFamily">Which per-family profile to apply. Determines ConnectionSize,
/// request-packing support, unconnected-only hint, and other quirks.</param>
/// <param name="DeviceName">Optional display label for Admin UI. Falls back to <see cref="HostAddress"/>.</param>
/// <param name="ConnectionSize">PR abcip-3.1 — optional override for the family-default
/// <see cref="PlcFamilies.AbCipPlcFamilyProfile.DefaultConnectionSize"/>. Threads through to
/// libplctag's <c>connection_size</c> 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 <c>InitializeAsync</c>; out-of-range values fault the
/// driver. <c>null</c> uses the family default — back-compat with deployments that haven't
/// touched the knob.</param>
public sealed record AbCipDeviceOptions(
string HostAddress,
AbCipPlcFamily PlcFamily = AbCipPlcFamily.ControlLogix,
string? DeviceName = null);
string? DeviceName = null,
int? ConnectionSize = null);
/// <summary>
/// One AB-backed OPC UA variable. Mirrors the <c>ModbusTagDefinition</c> shape.

View File

@@ -73,6 +73,13 @@ public interface IAbCipTagFactory
/// 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>
/// <param name="ConnectionSize">PR abcip-3.1 — CIP Forward Open buffer size in bytes. Threads
/// through to libplctag's <c>connection_size</c> attribute. The driver always supplies a
/// value here — either the per-device <see cref="AbCipDeviceOptions.ConnectionSize"/>
/// override or the family profile's <see cref="PlcFamilies.AbCipPlcFamilyProfile.DefaultConnectionSize"/>.
/// 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).</param>
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);

View File

@@ -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 <c>connection_size</c> as a public Tag
// property, so we reach into the internal <c>NativeTagWrapper</c>'s
// <c>SetIntAttribute</c> (mirroring libplctag's <c>plc_tag_set_int_attribute</c>).
// libplctag native parses <c>connection_size</c> 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);
/// <summary>
/// Best-effort propagation of <c>connection_size</c> to libplctag native. Reflects into
/// the wrapper's internal <c>NativeTagWrapper.SetIntAttribute(string, int)</c>; isolated
/// in a static helper so the lookup costs run once + the failure path is one line.
/// </summary>
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);

View File

@@ -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;
/// <summary>
/// PR abcip-3.1 — coverage for the per-device CIP <c>ConnectionSize</c> override.
/// Asserts (a) the value flows from <see cref="AbCipDeviceOptions"/> into every
/// <see cref="AbCipTagCreateParams"/> the driver builds, (b) the family default kicks in
/// when the override is unset, (c) values outside the Kepware-supported range are rejected
/// at <c>InitializeAsync</c>, and (d) the legacy-firmware warning fires when a CompactLogix
/// narrow-cap device is configured above 511 bytes.
/// </summary>
[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<InvalidOperationException>(
() => 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<string>();
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<string>();
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<string>();
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<string>();
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);
}
}