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