diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/AbLegacyCommandBase.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/AbLegacyCommandBase.cs
index 49b4dfcd..36074744 100644
--- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/AbLegacyCommandBase.cs
+++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/AbLegacyCommandBase.cs
@@ -25,6 +25,12 @@ public abstract class AbLegacyCommandBase : DriverCommandBase
"Slc500 / MicroLogix / Plc5 / LogixPccc (default Slc500).")]
public AbLegacyPlcFamily PlcType { get; init; } = AbLegacyPlcFamily.Slc500;
+ /// Gets the transport stack used to reach the PLC.
+ [CommandOption("transport", Description =
+ "Transport stack: libplctag (default) or native (managed EtherNet/IP + PCCC; PLC-5 N-file reads only, " +
+ "no status-bit writes, LogixPccc unverified).")]
+ public AbLegacyTransport Transport { get; init; } = AbLegacyTransport.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;
@@ -56,6 +62,7 @@ public abstract class AbLegacyCommandBase : DriverCommandBase
RawTags = [.. tags.Select(t => new RawTagEntry(
t.Name, AbLegacyTagDefinitionFactory.ToTagConfig(t), t.WriteIdempotent, DeviceName: t.DeviceHostAddress))],
Timeout = Timeout,
+ Transport = Transport,
Probe = new AbLegacyProbeOptions { Enabled = false },
};
diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Program.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Program.cs
index 7db0c5fb..be23d9e9 100644
--- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Program.cs
+++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Program.cs
@@ -5,7 +5,7 @@ return await new CliApplicationBuilder()
.SetExecutableName("otopcua-ablegacy-cli")
.SetDescription(
"OtOpcUa AB Legacy test-client — ad-hoc probe + PCCC N/F/B/L-file reads/writes + " +
- "polled subscriptions against SLC 500 / MicroLogix / PLC-5 devices via libplctag. " +
+ "polled subscriptions against SLC 500 / MicroLogix / PLC-5 devices via libplctag or the native transport. " +
"Addresses use PCCC convention: N7:0, F8:0, B3:0/3, L19:0, ST17:0.")
.Build()
.RunAsync(args);
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs
index c9376a09..15c8b754 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs
@@ -25,6 +25,16 @@ public sealed class AbLegacyDriverOptions
///
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 tag factory is 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 AbLegacyTransport Transport { get; init; } = AbLegacyTransport.Libplctag;
+
/// Gets or sets the probe (connectivity check) options.
public AbLegacyProbeOptions Probe { get; init; } = new();
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTransport.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTransport.cs
new file mode 100644
index 00000000..0ba96d15
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTransport.cs
@@ -0,0 +1,17 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
+
+///
+/// Which PCCC transport stack an AbLegacy driver instance (and its Test Connect probe / CLI run)
+/// talks to the controller through. Serialises as a STRING in driver config JSON.
+///
+public enum AbLegacyTransport
+{
+ /// The libplctag-backed transport. The default.
+ Libplctag,
+
+ ///
+ /// The native managed EtherNet/IP + PCCC transport. Known gaps: PLC-5 supports N-file reads only,
+ /// writes to status-bit sub-elements are unsupported, and LogixPccc framing is unverified.
+ ///
+ Native,
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
index c4d5d5e6..07eae5d1 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
@@ -68,7 +68,11 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
ArgumentNullException.ThrowIfNull(options);
_options = options;
_driverInstanceId = driverInstanceId;
- _tagFactory = tagFactory ?? new LibplctagLegacyTagFactory();
+ // Chosen once here and held across ReinitializeAsync (Shutdown+Initialize): a changed
+ // Transport in a reinit config is inert; a deploy recreates the instance instead.
+ _tagFactory = tagFactory ?? (options.Transport == AbLegacyTransport.Native
+ ? new NativeAbLegacyTagFactory()
+ : new LibplctagLegacyTagFactory());
_logger = logger ?? NullLogger.Instance;
_resolver = new EquipmentTagRefResolver(
r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
@@ -85,6 +89,9 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
new KeyValuePair(OtOpcUaTelemetry.PollOverrunDriverInstanceTag, driverInstanceId)));
}
+ /// The tag factory chosen at construction; exposed so tests can pin transport selection.
+ internal IAbLegacyTagFactory 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.AbLegacy/AbLegacyDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs
index cf55b578..dc8b0aff 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs
@@ -97,6 +97,8 @@ public static class AbLegacyDriverFactoryExtensions
ProbeAddress = dto.Probe?.ProbeAddress ?? "S:0",
},
Timeout = PositiveTimeoutOrDefault(dto.TimeoutMs, 2_000),
+ Transport = ParseEnum(dto.Transport, driverInstanceId, "Transport",
+ fallback: AbLegacyTransport.Libplctag),
};
return options;
@@ -146,6 +148,11 @@ public static class AbLegacyDriverFactoryExtensions
///
public int? TimeoutMs { get; init; }
+ ///
+ /// Gets or sets the transport (Libplctag or Native, case-insensitive); absent means Libplctag.
+ ///
+ public string? Transport { get; init; }
+
///
/// Gets or sets the list of devices to connect to.
///
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs
index 62bfe1fa..2856a491 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs
@@ -30,12 +30,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
///
public sealed class AbLegacyDriverProbe : IDriverProbe
{
- private readonly IAbLegacyTagFactory _tagFactory;
+ private readonly IAbLegacyTagFactory? _tagFactory;
/// Initializes a new instance of the class.
- /// The tag factory used for the PCCC handshake; defaults to the libplctag-backed one.
- public AbLegacyDriverProbe(IAbLegacyTagFactory? tagFactory = null) =>
- _tagFactory = tagFactory ?? new LibplctagLegacyTagFactory();
+ /// The tag factory used for the PCCC handshake; when null the config's
+ /// Transport selects the native or libplctag-backed one.
+ public AbLegacyDriverProbe(IAbLegacyTagFactory? tagFactory = null) => _tagFactory = tagFactory;
private static readonly JsonSerializerOptions _opts = new()
{
@@ -106,7 +106,10 @@ public sealed class AbLegacyDriverProbe : IDriverProbe
TagName: tagName,
Timeout: timeout);
- var rt = _tagFactory.Create(p);
+ var factory = _tagFactory ?? (opts.Transport == AbLegacyTransport.Native
+ ? new NativeAbLegacyTagFactory()
+ : new LibplctagLegacyTagFactory());
+ var rt = factory.Create(p);
try
{
await rt.InitializeAsync(cts.Token);
diff --git a/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests/BuildOptionsTests.cs b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests/BuildOptionsTests.cs
index 0b60a179..a3bc9231 100644
--- a/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests/BuildOptionsTests.cs
+++ b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests/BuildOptionsTests.cs
@@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
+using System.Threading.Tasks;
+using CliFx;
using CliFx.Attributes;
using CliFx.Infrastructure;
using Shouldly;
@@ -158,4 +160,44 @@ public sealed class BuildOptionsTests
options.Devices.Count.ShouldBe(1);
options.Devices[0].PlcFamily.ShouldBe(AbLegacyPlcFamily.Plc5);
}
+
+ /// Verifies the transport defaults to libplctag and --transport reaches the options.
+ [Fact]
+ public void BuildOptions_defaults_to_libplctag_and_forwards_transport()
+ {
+ var def = new TestCommand { Gateway = "ab://192.168.1.20/1,0" };
+ def.Build([]).Transport.ShouldBe(AbLegacyTransport.Libplctag);
+
+ var native = new TestCommand { Gateway = "ab://192.168.1.20/1,0", Transport = AbLegacyTransport.Native };
+ native.Build([]).Transport.ShouldBe(AbLegacyTransport.Native);
+ }
+
+ /// Verifies CliFx really parses --transport native (case-insensitive) into the property.
+ [Theory]
+ [InlineData("native", AbLegacyTransport.Native)]
+ [InlineData("Native", AbLegacyTransport.Native)]
+ [InlineData("libplctag", AbLegacyTransport.Libplctag)]
+ public async Task Transport_flag_is_parsed_by_the_cli(string value, AbLegacyTransport 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);
+ }
+
+ [Command("capture-transport")]
+ private sealed class CapturingCommand : AbLegacyCommandBase
+ {
+ public static AbLegacyTransport? Captured;
+
+ public override System.Threading.Tasks.ValueTask ExecuteAsync(IConsole console)
+ {
+ Captured = BuildOptions([]).Transport;
+ return default;
+ }
+ }
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyTransportSwitchTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyTransportSwitchTests.cs
new file mode 100644
index 00000000..711bcbcf
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyTransportSwitchTests.cs
@@ -0,0 +1,117 @@
+using System.Net;
+using System.Net.Sockets;
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
+
+/// The per-driver Transport switch: config parsing, driver factory selection and the probe.
+[Trait("Category", "Unit")]
+public sealed class AbLegacyTransportSwitchTests
+{
+ private const string Device = """{"hostAddress":"ab://10.0.0.1:44818/1,0","plcFamily":"Slc500"}""";
+
+ private static string Config(string? transportJson) =>
+ $$"""{{{(transportJson is null ? "" : $"\"transport\":{transportJson},")}}"devices":[{{Device}}]}""";
+
+ /// Transport values select the factory type; absent defaults to libplctag.
+ [Theory]
+ [InlineData("\"Native\"", AbLegacyTransport.Native)]
+ [InlineData("\"native\"", AbLegacyTransport.Native)]
+ [InlineData("\"Libplctag\"", AbLegacyTransport.Libplctag)]
+ [InlineData(null, AbLegacyTransport.Libplctag)]
+ public void Config_transport_selects_the_factory(string? json, AbLegacyTransport expected)
+ {
+ AbLegacyDriverFactoryExtensions.ParseOptions("ab1", Config(json)).Transport.ShouldBe(expected);
+
+ var driver = AbLegacyDriverFactoryExtensions.CreateInstance("ab1", Config(json));
+ if (expected == AbLegacyTransport.Native)
+ driver.TagFactory.ShouldBeOfType();
+ else
+ driver.TagFactory.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(
+ () => AbLegacyDriverFactoryExtensions.ParseOptions("ab1", Config("\"Bogus\"")));
+ bad.Message.ShouldContain("unknown Transport 'Bogus'");
+
+ var existing = Should.Throw(() => AbLegacyDriverFactoryExtensions.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 FakeAbLegacyTagFactory();
+ var driver = new AbLegacyDriver(new AbLegacyDriverOptions { Transport = AbLegacyTransport.Native }, "ab1", fake);
+ driver.TagFactory.ShouldBeSameAs(fake);
+ }
+
+ ///
+ /// Reinit does NOT swap transports (by design): the factory is 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 = AbLegacyDriverFactoryExtensions.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;
+ var accept = Task.Run(async () =>
+ {
+ while (true)
+ {
+ try { (await listener.AcceptTcpClientAsync(TestContext.Current.CancellationToken)).Dispose(); }
+ catch { return; }
+ }
+ });
+
+ string Cfg(string t) =>
+ $$"""{"transport":"{{t}}","devices":[{"hostAddress":"ab://127.0.0.1:{{port}}/1,0"}]}""";
+ var probe = new AbLegacyDriverProbe();
+
+ 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();
+ // "Connection lost" is the managed EncapsulationClient's wording; libplctag reports a bare status name.
+ native.Message!.ShouldContain("PCCC 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 AbLegacyDriverProbe().ProbeAsync(
+ Config("\"Bogus\""), TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken);
+ r.Ok.ShouldBeFalse();
+ r.Message!.ShouldContain("Config JSON is invalid");
+ }
+}