feat(abcip): Transport option selects native or libplctag across driver, probe and CLI

This commit is contained in:
Joseph Doherty
2026-10-07 17:13:37 -04:00
parent 1211cd9a83
commit 45f6e1793f
10 changed files with 252 additions and 8 deletions
@@ -26,6 +26,11 @@ public abstract class AbCipCommandBase : DriverCommandBase
"ControlLogix / CompactLogix / Micro800 / GuardLogix (default ControlLogix).")]
public AbCipPlcFamily Family { get; init; } = AbCipPlcFamily.ControlLogix;
/// <summary>Gets the transport stack used to reach the PLC.</summary>
[CommandOption("transport", Description =
"Transport stack: libplctag (default) or native (managed EtherNet/IP + CIP).")]
public AbCipTransport Transport { get; init; } = AbCipTransport.Libplctag;
/// <summary>Gets the per-operation timeout in milliseconds.</summary>
[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,
@@ -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);
@@ -32,6 +32,16 @@ public sealed class AbCipDriverOptions
/// </summary>
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];
/// <summary>
/// Which transport stack the driver, its Test Connect probe and the CLI use. Defaults to
/// <see cref="AbCipTransport.Libplctag"/>.
/// <para>Changing this takes effect when the driver instance is RECREATED, not on
/// <c>ReinitializeAsync</c>: 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
/// (<c>DriverSpawnPlanner</c>, #516), so an operator edit does recreate it.</para>
/// </summary>
public AbCipTransport Transport { get; init; } = AbCipTransport.Libplctag;
/// <summary>Per-device probe settings. Falls back to defaults when omitted.</summary>
public AbCipProbeOptions Probe { get; init; } = new();
@@ -0,0 +1,14 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// 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.
/// </summary>
public enum AbCipTransport
{
/// <summary>The libplctag-backed transport. The default.</summary>
Libplctag,
/// <summary>The native managed EtherNet/IP + CIP transport (shared Driver.Ab.Transport stack).</summary>
Native,
}
@@ -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<AbCipDriver>.Instance;
_resolver = new EquipmentTagRefResolver<AbCipTagDefinition>(
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);
}
/// <summary>The tag factory chosen at construction; exposed so tests can pin transport selection.</summary>
internal IAbCipTagFactory TagFactory => _tagFactory;
/// <summary>Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8).</summary>
private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30);
@@ -81,6 +81,8 @@ public static class AbCipDriverFactoryExtensions
ProbeTagPath = dto.Probe?.ProbeTagPath,
},
Timeout = PositiveTimeoutOrDefault(dto.TimeoutMs, 2_000),
Transport = ParseEnum<AbCipTransport>(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
{
/// <summary>
/// Gets or sets the transport (<c>Libplctag</c> or <c>Native</c>, case-insensitive); absent means Libplctag.
/// </summary>
public string? Transport { get; init; }
/// <summary>
/// Gets or sets the timeout in milliseconds for operations.
/// </summary>
@@ -33,11 +33,12 @@ public sealed class AbCipDriverProbe : IDriverProbe
Converters = { new JsonStringEnumConverter() },
};
private readonly IAbCipTagFactory _tagFactory;
private readonly IAbCipTagFactory? _tagFactory;
/// <summary>Initializes a new instance of the <see cref="AbCipDriverProbe"/> class.</summary>
/// <param name="tagFactory">Factory for the probe's tag runtime; defaults to the libplctag-backed one.</param>
public AbCipDriverProbe(IAbCipTagFactory? tagFactory = null) => _tagFactory = tagFactory ?? new LibplctagTagFactory();
/// <param name="tagFactory">Factory for the probe's tag runtime; when null the config's
/// <c>Transport</c> selects the native or libplctag-backed one.</param>
public AbCipDriverProbe(IAbCipTagFactory? tagFactory = null) => _tagFactory = tagFactory;
/// <inheritdoc />
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);
@@ -0,0 +1,14 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>Maps an <see cref="AbCipTransport"/> to the matching trio of runtime factories.</summary>
internal static class AbCipTransportFactories
{
/// <summary>Returns the tag, enumerator and template-reader factories for <paramref name="transport"/>.</summary>
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()),
};
}
@@ -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;
/// <summary>The CLI <c>--transport</c> flag reaches <see cref="AbCipDriverOptions.Transport"/>.</summary>
[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;
}
}
/// <summary>Verifies the default is libplctag when the flag is absent.</summary>
[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);
}
/// <summary>Verifies CliFx parses <c>--transport</c> (case-insensitive) into the options.</summary>
[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);
}
}
@@ -0,0 +1,131 @@
using System.Net;
using System.Net.Sockets;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>The per-driver <c>Transport</c> switch: config parsing, driver factory selection and the probe.</summary>
[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}}]}""";
/// <summary>Transport values select the factory trio; absent defaults to libplctag.</summary>
[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<NativeAbCipTagFactory>();
else
driver.TagFactory.ShouldBeOfType<LibplctagTagFactory>();
}
/// <summary>The factory trio matches the transport for all three factory kinds.</summary>
[Fact]
public void Factories_For_returns_a_matching_trio()
{
var n = AbCipTransportFactories.For(AbCipTransport.Native);
n.Tags.ShouldBeOfType<NativeAbCipTagFactory>();
n.Enumerators.ShouldBeOfType<NativeAbCipTagEnumeratorFactory>();
n.Templates.ShouldBeOfType<NativeAbCipTemplateReaderFactory>();
var l = AbCipTransportFactories.For(AbCipTransport.Libplctag);
l.Tags.ShouldBeOfType<LibplctagTagFactory>();
l.Enumerators.ShouldBeOfType<LibplctagTagEnumeratorFactory>();
l.Templates.ShouldBeOfType<LibplctagTemplateReaderFactory>();
}
/// <summary>A bad value fails the way an existing bad enum field (PlcFamily) does.</summary>
[Fact]
public void Bad_transport_fails_like_a_bad_PlcFamily()
{
var bad = Should.Throw<InvalidOperationException>(
() => AbCipDriverFactoryExtensions.ParseOptions("ab1", Config("\"Bogus\"")));
bad.Message.ShouldContain("unknown Transport 'Bogus'");
var existing = Should.Throw<InvalidOperationException>(() => 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'");
}
/// <summary>An explicitly injected factory wins over the option.</summary>
[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);
}
/// <summary>
/// Reinit does NOT swap transports (by design): the factories are constructor-held, so a changed
/// <c>transport</c> in the reinit JSON is inert. A deploy recreates the instance instead (#516).
/// </summary>
[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<LibplctagTagFactory>();
}
/// <summary>
/// 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.
/// </summary>
[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");
}
/// <summary>The probe deserialises with its own string-enum converter; a bad transport is a config error.</summary>
[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");
}
}