feat(ablegacy): Transport option selects native or libplctag across driver, probe and CLI
This commit is contained in:
@@ -25,6 +25,12 @@ public abstract class AbLegacyCommandBase : DriverCommandBase
|
||||
"Slc500 / MicroLogix / Plc5 / LogixPccc (default Slc500).")]
|
||||
public AbLegacyPlcFamily PlcType { get; init; } = AbLegacyPlcFamily.Slc500;
|
||||
|
||||
/// <summary>Gets the transport stack used to reach the PLC.</summary>
|
||||
[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;
|
||||
|
||||
/// <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;
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -25,6 +25,16 @@ public sealed class AbLegacyDriverOptions
|
||||
/// </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="AbLegacyTransport.Libplctag"/>.
|
||||
/// <para>Changing this takes effect when the driver instance is RECREATED, not on
|
||||
/// <c>ReinitializeAsync</c>: 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
|
||||
/// (<c>DriverSpawnPlanner</c>, #516), so an operator edit does recreate it.</para>
|
||||
/// </summary>
|
||||
public AbLegacyTransport Transport { get; init; } = AbLegacyTransport.Libplctag;
|
||||
|
||||
/// <summary>Gets or sets the probe (connectivity check) options.</summary>
|
||||
public AbLegacyProbeOptions Probe { get; init; } = new();
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public enum AbLegacyTransport
|
||||
{
|
||||
/// <summary>The libplctag-backed transport. The default.</summary>
|
||||
Libplctag,
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
Native,
|
||||
}
|
||||
@@ -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<AbLegacyDriver>.Instance;
|
||||
_resolver = new EquipmentTagRefResolver<AbLegacyTagDefinition>(
|
||||
r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
|
||||
@@ -85,6 +89,9 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
new KeyValuePair<string, object?>(OtOpcUaTelemetry.PollOverrunDriverInstanceTag, driverInstanceId)));
|
||||
}
|
||||
|
||||
/// <summary>The tag factory chosen at construction; exposed so tests can pin transport selection.</summary>
|
||||
internal IAbLegacyTagFactory 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);
|
||||
|
||||
|
||||
@@ -97,6 +97,8 @@ public static class AbLegacyDriverFactoryExtensions
|
||||
ProbeAddress = dto.Probe?.ProbeAddress ?? "S:0",
|
||||
},
|
||||
Timeout = PositiveTimeoutOrDefault(dto.TimeoutMs, 2_000),
|
||||
Transport = ParseEnum<AbLegacyTransport>(dto.Transport, driverInstanceId, "Transport",
|
||||
fallback: AbLegacyTransport.Libplctag),
|
||||
};
|
||||
|
||||
return options;
|
||||
@@ -146,6 +148,11 @@ public static class AbLegacyDriverFactoryExtensions
|
||||
/// </summary>
|
||||
public int? TimeoutMs { get; init; }
|
||||
|
||||
/// <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 list of devices to connect to.
|
||||
/// </summary>
|
||||
|
||||
@@ -30,12 +30,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
/// </summary>
|
||||
public sealed class AbLegacyDriverProbe : IDriverProbe
|
||||
{
|
||||
private readonly IAbLegacyTagFactory _tagFactory;
|
||||
private readonly IAbLegacyTagFactory? _tagFactory;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="AbLegacyDriverProbe"/> class.</summary>
|
||||
/// <param name="tagFactory">The tag factory used for the PCCC handshake; defaults to the libplctag-backed one.</param>
|
||||
public AbLegacyDriverProbe(IAbLegacyTagFactory? tagFactory = null) =>
|
||||
_tagFactory = tagFactory ?? new LibplctagLegacyTagFactory();
|
||||
/// <param name="tagFactory">The tag factory used for the PCCC handshake; when null the config's
|
||||
/// <c>Transport</c> selects the native or libplctag-backed one.</param>
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies the transport defaults to libplctag and --transport reaches the options.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies CliFx really parses <c>--transport native</c> (case-insensitive) into the property.</summary>
|
||||
[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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
/// <summary>The per-driver <c>Transport</c> switch: config parsing, driver factory selection and the probe.</summary>
|
||||
[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}}]}""";
|
||||
|
||||
/// <summary>Transport values select the factory type; absent defaults to libplctag.</summary>
|
||||
[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<NativeAbLegacyTagFactory>();
|
||||
else
|
||||
driver.TagFactory.ShouldBeOfType<LibplctagLegacyTagFactory>();
|
||||
}
|
||||
|
||||
/// <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>(
|
||||
() => AbLegacyDriverFactoryExtensions.ParseOptions("ab1", Config("\"Bogus\"")));
|
||||
bad.Message.ShouldContain("unknown Transport 'Bogus'");
|
||||
|
||||
var existing = Should.Throw<InvalidOperationException>(() => 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'");
|
||||
}
|
||||
|
||||
/// <summary>An explicitly injected factory wins over the option.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reinit does NOT swap transports (by design): the factory is 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 = AbLegacyDriverFactoryExtensions.CreateInstance("ab1", Config("\"Libplctag\""));
|
||||
var before = driver.TagFactory;
|
||||
|
||||
await driver.ReinitializeAsync(Config("\"Native\""), TestContext.Current.CancellationToken);
|
||||
|
||||
driver.TagFactory.ShouldBeSameAs(before);
|
||||
driver.TagFactory.ShouldBeOfType<LibplctagLegacyTagFactory>();
|
||||
}
|
||||
|
||||
/// <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;
|
||||
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");
|
||||
}
|
||||
|
||||
/// <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 AbLegacyDriverProbe().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