From 45f6e1793f98c52a26c2a39ccea7e68c5f9ec2fb Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 7 Oct 2026 17:13:37 -0400 Subject: [PATCH] feat(abcip): Transport option selects native or libplctag across driver, probe and CLI --- .../AbCipCommandBase.cs | 6 + .../Program.cs | 2 +- .../AbCipDriverOptions.cs | 10 ++ .../AbCipTransport.cs | 14 ++ .../AbCipDriver.cs | 12 +- .../AbCipDriverFactoryExtensions.cs | 7 + .../AbCipDriverProbe.cs | 9 +- .../AbCipTransportFactories.cs | 14 ++ .../AbCipTransportOptionTests.cs | 55 ++++++++ .../AbCipTransportSwitchTests.cs | 131 ++++++++++++++++++ 10 files changed, 252 insertions(+), 8 deletions(-) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipTransport.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTransportFactories.cs create mode 100644 tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests/AbCipTransportOptionTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTransportSwitchTests.cs diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/AbCipCommandBase.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/AbCipCommandBase.cs index c69d04b1..dc4e6b45 100644 --- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/AbCipCommandBase.cs +++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/AbCipCommandBase.cs @@ -26,6 +26,11 @@ public abstract class AbCipCommandBase : DriverCommandBase "ControlLogix / CompactLogix / Micro800 / GuardLogix (default ControlLogix).")] public AbCipPlcFamily Family { get; init; } = AbCipPlcFamily.ControlLogix; + /// Gets the transport stack used to reach the PLC. + [CommandOption("transport", Description = + "Transport stack: libplctag (default) or native (managed EtherNet/IP + CIP).")] + public AbCipTransport Transport { get; init; } = AbCipTransport.Libplctag; + /// Gets the per-operation timeout in milliseconds. [CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")] public int TimeoutMs { get; init; } = 5000; @@ -68,6 +73,7 @@ public abstract class AbCipCommandBase : DriverCommandBase WriteIdempotent: t.WriteIdempotent, DeviceName: t.DeviceHostAddress))], Timeout = Timeout, + Transport = Transport, Probe = new AbCipProbeOptions { Enabled = false }, EnableControllerBrowse = false, EnableAlarmProjection = false, diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Program.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Program.cs index 2f561e1d..df586a3d 100644 --- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Program.cs +++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Program.cs @@ -6,6 +6,6 @@ return await new CliApplicationBuilder() .SetDescription( "OtOpcUa AB CIP test-client — ad-hoc probe + Logix symbolic reads/writes + polled " + "subscriptions against ControlLogix / CompactLogix / Micro800 / GuardLogix families " + - "via libplctag. Second of six driver CLIs; mirrors otopcua-modbus-cli's shape.") + "via libplctag or the native transport. Second of six driver CLIs; mirrors otopcua-modbus-cli's shape.") .Build() .RunAsync(args); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs index 4e730422..3c2b37ea 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs @@ -32,6 +32,16 @@ public sealed class AbCipDriverOptions /// public IReadOnlyList RawTags { get; init; } = []; + /// + /// Which transport stack the driver, its Test Connect probe and the CLI use. Defaults to + /// . + /// Changing this takes effect when the driver instance is RECREATED, not on + /// ReinitializeAsync: the factories are chosen once in the constructor and held across + /// Shutdown+Initialize. A deploy that changes the driver config stops and respawns the instance + /// (DriverSpawnPlanner, #516), so an operator edit does recreate it. + /// + public AbCipTransport Transport { get; init; } = AbCipTransport.Libplctag; + /// Per-device probe settings. Falls back to defaults when omitted. public AbCipProbeOptions Probe { get; init; } = new(); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipTransport.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipTransport.cs new file mode 100644 index 00000000..f1ab9889 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipTransport.cs @@ -0,0 +1,14 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +/// +/// Which CIP transport stack an AbCip driver instance (and its Test Connect probe / CLI run) +/// talks to the controller through. Serialises as a STRING in driver config JSON. +/// +public enum AbCipTransport +{ + /// The libplctag-backed transport. The default. + Libplctag, + + /// The native managed EtherNet/IP + CIP transport (shared Driver.Ab.Transport stack). + Native, +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs index d0713112..99f400c2 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -150,9 +150,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, ArgumentNullException.ThrowIfNull(options); _options = options; _driverInstanceId = driverInstanceId; - _tagFactory = tagFactory ?? new LibplctagTagFactory(); - _enumeratorFactory = enumeratorFactory ?? new LibplctagTagEnumeratorFactory(); - _templateReaderFactory = templateReaderFactory ?? new LibplctagTemplateReaderFactory(); + // Chosen once here and held across ReinitializeAsync (Shutdown+Initialize): a changed + // Transport in a reinit config is inert; a deploy recreates the instance instead. + var defaults = AbCipTransportFactories.For(options.Transport); + _tagFactory = tagFactory ?? defaults.Tags; + _enumeratorFactory = enumeratorFactory ?? defaults.Enumerators; + _templateReaderFactory = templateReaderFactory ?? defaults.Templates; _logger = logger ?? NullLogger.Instance; _resolver = new EquipmentTagRefResolver( r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null); @@ -172,6 +175,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, _alarmProjection = new AbCipAlarmProjection(this, _options.AlarmPollInterval, _logger); } + /// The tag factory chosen at construction; exposed so tests can pin transport selection. + internal IAbCipTagFactory TagFactory => _tagFactory; + /// Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8). private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs index 6d8e176c..4ce4ca72 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs @@ -81,6 +81,8 @@ public static class AbCipDriverFactoryExtensions ProbeTagPath = dto.Probe?.ProbeTagPath, }, Timeout = PositiveTimeoutOrDefault(dto.TimeoutMs, 2_000), + Transport = ParseEnum(dto.Transport, "driver", driverInstanceId, "Transport", + fallback: AbCipTransport.Libplctag), EnableControllerBrowse = dto.EnableControllerBrowse ?? false, EnableAlarmProjection = dto.EnableAlarmProjection ?? false, AlarmPollInterval = TimeSpan.FromMilliseconds(dto.AlarmPollIntervalMs ?? 1_000), @@ -128,6 +130,11 @@ public static class AbCipDriverFactoryExtensions internal sealed class AbCipDriverConfigDto { + /// + /// Gets or sets the transport (Libplctag or Native, case-insensitive); absent means Libplctag. + /// + public string? Transport { get; init; } + /// /// Gets or sets the timeout in milliseconds for operations. /// diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs index 4f26a685..7fe144c3 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs @@ -33,11 +33,12 @@ public sealed class AbCipDriverProbe : IDriverProbe Converters = { new JsonStringEnumConverter() }, }; - private readonly IAbCipTagFactory _tagFactory; + private readonly IAbCipTagFactory? _tagFactory; /// Initializes a new instance of the class. - /// Factory for the probe's tag runtime; defaults to the libplctag-backed one. - public AbCipDriverProbe(IAbCipTagFactory? tagFactory = null) => _tagFactory = tagFactory ?? new LibplctagTagFactory(); + /// Factory for the probe's tag runtime; when null the config's + /// Transport selects the native or libplctag-backed one. + public AbCipDriverProbe(IAbCipTagFactory? tagFactory = null) => _tagFactory = tagFactory; /// public string DriverType => "AbCip"; @@ -122,7 +123,7 @@ public sealed class AbCipDriverProbe : IDriverProbe ConnectionSize: firstDevice?.ConnectionSize ?? profile.DefaultConnectionSize, UseConnectedMessaging: profile.SupportsConnectedMessaging); - var rt = _tagFactory.Create(p); + var rt = (_tagFactory ?? AbCipTransportFactories.For(opts.Transport).Tags).Create(p); try { await rt.InitializeAsync(cts.Token); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTransportFactories.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTransportFactories.cs new file mode 100644 index 00000000..6362875a --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTransportFactories.cs @@ -0,0 +1,14 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +/// Maps an to the matching trio of runtime factories. +internal static class AbCipTransportFactories +{ + /// Returns the tag, enumerator and template-reader factories for . + public static (IAbCipTagFactory Tags, IAbCipTagEnumeratorFactory Enumerators, IAbCipTemplateReaderFactory Templates) + For(AbCipTransport transport) => transport switch + { + AbCipTransport.Native => (new NativeAbCipTagFactory(), new NativeAbCipTagEnumeratorFactory(), + new NativeAbCipTemplateReaderFactory()), + _ => (new LibplctagTagFactory(), new LibplctagTagEnumeratorFactory(), new LibplctagTemplateReaderFactory()), + }; +} diff --git a/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests/AbCipTransportOptionTests.cs b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests/AbCipTransportOptionTests.cs new file mode 100644 index 00000000..6c36ce95 --- /dev/null +++ b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests/AbCipTransportOptionTests.cs @@ -0,0 +1,55 @@ +using CliFx; +using CliFx.Attributes; +using CliFx.Infrastructure; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests; + +/// The CLI --transport flag reaches . +[Trait("Category", "Unit")] +public sealed class AbCipTransportOptionTests +{ + [Command("capture-transport")] + private sealed class CapturingCommand : AbCipCommandBase + { + public static AbCipTransport? Captured; + + public override ValueTask ExecuteAsync(IConsole console) + { + Captured = BuildOptions([]).Transport; + return default; + } + } + + /// Verifies the default is libplctag when the flag is absent. + [Fact] + public async Task Transport_defaults_to_libplctag() + { + CapturingCommand.Captured = null; + using var console = new FakeInMemoryConsole(); + var app = new CliApplicationBuilder().AddCommand(typeof(CapturingCommand)).UseConsole(console).Build(); + + var exit = await app.RunAsync(["capture-transport", "--gateway", "ab://1.2.3.4/1,0"]); + + exit.ShouldBe(0, console.ReadErrorString()); + CapturingCommand.Captured.ShouldBe(AbCipTransport.Libplctag); + } + + /// Verifies CliFx parses --transport (case-insensitive) into the options. + [Theory] + [InlineData("native", AbCipTransport.Native)] + [InlineData("Native", AbCipTransport.Native)] + [InlineData("libplctag", AbCipTransport.Libplctag)] + public async Task Transport_flag_is_parsed_by_the_cli(string value, AbCipTransport expected) + { + CapturingCommand.Captured = null; + using var console = new FakeInMemoryConsole(); + var app = new CliApplicationBuilder().AddCommand(typeof(CapturingCommand)).UseConsole(console).Build(); + + var exit = await app.RunAsync(["capture-transport", "--gateway", "ab://1.2.3.4/1,0", "--transport", value]); + + exit.ShouldBe(0, console.ReadErrorString()); + CapturingCommand.Captured.ShouldBe(expected); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTransportSwitchTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTransportSwitchTests.cs new file mode 100644 index 00000000..a657b891 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTransportSwitchTests.cs @@ -0,0 +1,131 @@ +using System.Net; +using System.Net.Sockets; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; + +/// The per-driver Transport switch: config parsing, driver factory selection and the probe. +[Trait("Category", "Unit")] +public sealed class AbCipTransportSwitchTests +{ + private const string Device = """{"hostAddress":"ab://10.0.0.1:44818/1,0","plcFamily":"ControlLogix"}"""; + + private static string Config(string? transportJson) => + $$"""{{{(transportJson is null ? "" : $"\"transport\":{transportJson},")}}"devices":[{{Device}}]}"""; + + /// Transport values select the factory trio; absent defaults to libplctag. + [Theory] + [InlineData("\"Native\"", AbCipTransport.Native)] + [InlineData("\"native\"", AbCipTransport.Native)] + [InlineData("\"Libplctag\"", AbCipTransport.Libplctag)] + [InlineData(null, AbCipTransport.Libplctag)] + public void Config_transport_selects_the_factory(string? json, AbCipTransport expected) + { + AbCipDriverFactoryExtensions.ParseOptions("ab1", Config(json)).Transport.ShouldBe(expected); + + var driver = AbCipDriverFactoryExtensions.CreateInstance("ab1", Config(json)); + if (expected == AbCipTransport.Native) + driver.TagFactory.ShouldBeOfType(); + else + driver.TagFactory.ShouldBeOfType(); + } + + /// The factory trio matches the transport for all three factory kinds. + [Fact] + public void Factories_For_returns_a_matching_trio() + { + var n = AbCipTransportFactories.For(AbCipTransport.Native); + n.Tags.ShouldBeOfType(); + n.Enumerators.ShouldBeOfType(); + n.Templates.ShouldBeOfType(); + + var l = AbCipTransportFactories.For(AbCipTransport.Libplctag); + l.Tags.ShouldBeOfType(); + l.Enumerators.ShouldBeOfType(); + l.Templates.ShouldBeOfType(); + } + + /// A bad value fails the way an existing bad enum field (PlcFamily) does. + [Fact] + public void Bad_transport_fails_like_a_bad_PlcFamily() + { + var bad = Should.Throw( + () => AbCipDriverFactoryExtensions.ParseOptions("ab1", Config("\"Bogus\""))); + bad.Message.ShouldContain("unknown Transport 'Bogus'"); + + var existing = Should.Throw(() => AbCipDriverFactoryExtensions.ParseOptions( + "ab1", """{"devices":[{"hostAddress":"ab://10.0.0.1/1,0","plcFamily":"Bogus"}]}""")); + existing.GetType().ShouldBe(bad.GetType()); + existing.Message.ShouldContain("unknown PlcFamily 'Bogus'"); + } + + /// An explicitly injected factory wins over the option. + [Fact] + public void Injected_factory_wins_over_the_option() + { + var fake = new FakeAbCipTagFactory(); + var driver = new AbCipDriver(new AbCipDriverOptions { Transport = AbCipTransport.Native }, "ab1", fake); + driver.TagFactory.ShouldBeSameAs(fake); + } + + /// + /// Reinit does NOT swap transports (by design): the factories are constructor-held, so a changed + /// transport in the reinit JSON is inert. A deploy recreates the instance instead (#516). + /// + [Fact] + public async Task Reinit_with_a_changed_transport_keeps_the_constructed_transport() + { + var driver = AbCipDriverFactoryExtensions.CreateInstance("ab1", Config("\"Libplctag\"")); + var before = driver.TagFactory; + + await driver.ReinitializeAsync(Config("\"Native\""), TestContext.Current.CancellationToken); + + driver.TagFactory.ShouldBeSameAs(before); + driver.TagFactory.ShouldBeOfType(); + } + + /// + /// The probe's Phase 1 TCP preflight rejects a refused port before any runtime exists, so the native + /// path is proven against a listener that accepts TCP and hangs up: only a runtime that really opens + /// an EtherNet/IP session reports the native transport's own failure text. + /// + [Fact] + public async Task Probe_with_Native_uses_the_native_path() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + _ = Task.Run(async () => + { + while (true) + { + try { (await listener.AcceptTcpClientAsync(TestContext.Current.CancellationToken)).Dispose(); } + catch { return; } + } + }, TestContext.Current.CancellationToken); + + string Cfg(string t) => + $$"""{"transport":"{{t}}","devices":[{"hostAddress":"ab://127.0.0.1:{{port}}/1,0"}]}"""; + var probe = new AbCipDriverProbe(); + + var native = await probe.ProbeAsync(Cfg("Native"), TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); + var lib = await probe.ProbeAsync(Cfg("Libplctag"), TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); + listener.Stop(); + + native.Ok.ShouldBeFalse(); + native.Message.ShouldNotBeNull(); + native.Message!.ShouldContain("CIP handshake failed: Connection lost"); + lib.Message.ShouldNotContain("Connection lost"); + } + + /// The probe deserialises with its own string-enum converter; a bad transport is a config error. + [Fact] + public async Task Probe_with_a_bad_transport_reports_invalid_config() + { + var r = await new AbCipDriverProbe().ProbeAsync( + Config("\"Bogus\""), TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken); + r.Ok.ShouldBeFalse(); + r.Message!.ShouldContain("Config JSON is invalid"); + } +}