feat(abcip): Transport option selects native or libplctag across driver, probe and CLI
This commit is contained in:
@@ -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()),
|
||||
};
|
||||
}
|
||||
+55
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user