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