Merge pull request '[abcip] AbCip — Configurable CIP Connection Size per device' (#367) from auto/abcip/3.1 into auto/driver-gaps
This commit was merged in pull request #367.
This commit is contained in:
@@ -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.
|
||||
|
||||
153
docs/drivers/AbCip-Performance.md
Normal file
153
docs/drivers/AbCip-Performance.md
Normal 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.
|
||||
@@ -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`
|
||||
|
||||
@@ -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": [
|
||||
|
||||
29
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipConnectionSize.cs
Normal file
29
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipConnectionSize.cs
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user