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