feat(probe): AbLegacy Test-Connect opens a real PCCC session (libplctag init)
Replaces the bare-TCP AbLegacyDriverProbe with a two-phase probe:
Phase 1 is the existing TCP preflight; Phase 2 initialises a
LibplctagLegacyTagRuntime (Protocol.ab_eip + per-family PlcType) to
open a real PCCC-over-EIP session, using AbLegacyProbeOptions.ProbeAddress
("S:0") as the probe tag. Status-code discrimination mirrors the AbCip
probe: ErrorNotFound/ErrorNoMatch/ErrorBadDevice → Ok=true "controller
reachable"; transport errors → Ok=false "handshake failed".
Adds AbLegacyDriverProbeTests (5 unit tests, all green, 168 total).
This commit is contained in:
@@ -2,16 +2,36 @@ using System.Diagnostics;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using libplctag;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// Cheap TCP-connect probe for the <see cref="AbLegacyDriverOptions"/>-shaped driver config.
|
||||
/// Opens a socket to the first device's gateway host + EtherNet/IP port and closes
|
||||
/// immediately. Surfaces a green tick + latency on success; red chip + SocketError on
|
||||
/// failure; "timed out" on the caller's cancellation. Does NOT exchange any PCCC bytes —
|
||||
/// a richer EIP session-open probe is a documented follow-up.
|
||||
/// Two-phase Test-Connect probe for the <see cref="AbLegacyDriverOptions"/>-shaped driver config.
|
||||
/// Phase 1: bare TCP connect to the first device's gateway host + EtherNet/IP port to
|
||||
/// quickly reject unreachable targets. Phase 2: initialises a libplctag <see cref="LibplctagLegacyTagRuntime"/>
|
||||
/// against the same device to open a real PCCC-over-EIP session — confirming the remote
|
||||
/// endpoint actually speaks PCCC, not just accepts TCP.
|
||||
/// A device that accepts the TCP connection but is not a PCCC controller (wrong path,
|
||||
/// non-PCCC server) returns <c>Ok = false</c> with a "handshake failed" message instead of
|
||||
/// a false-positive green tick.
|
||||
/// <para>
|
||||
/// Status-code discrimination: libplctag statuses that indicate the controller answered
|
||||
/// the PCCC session but could not resolve the probe tag
|
||||
/// (<see cref="Status.ErrorNotFound"/>, <see cref="Status.ErrorNoMatch"/>,
|
||||
/// <see cref="Status.ErrorBadDevice"/>) are treated as "reachable — Ok = true" because
|
||||
/// a non-existent tag name proves PCCC connectivity. Statuses that indicate a transport or
|
||||
/// session failure (<see cref="Status.ErrorTimeout"/>, <see cref="Status.ErrorBadConnection"/>,
|
||||
/// <see cref="Status.ErrorBadGateway"/>, <see cref="Status.ErrorWinsock"/>,
|
||||
/// <see cref="Status.ErrorOpen"/>, <see cref="Status.ErrorClose"/>,
|
||||
/// <see cref="Status.ErrorRead"/>, <see cref="Status.ErrorWrite"/>,
|
||||
/// <see cref="Status.ErrorBadReply"/>, <see cref="Status.ErrorRemoteErr"/>,
|
||||
/// <see cref="Status.ErrorPartial"/>, <see cref="Status.ErrorAbort"/>) are treated as
|
||||
/// "handshake failed — Ok = false". All other error statuses default to
|
||||
/// "handshake failed" (conservative).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class AbLegacyDriverProbe : IDriverProbe
|
||||
{
|
||||
@@ -32,17 +52,19 @@ public sealed class AbLegacyDriverProbe : IDriverProbe
|
||||
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
|
||||
if (opts is null) return new(false, "Config JSON deserialized to null.", null);
|
||||
|
||||
var (host, port) = ExtractTarget(opts);
|
||||
if (string.IsNullOrWhiteSpace(host) || port <= 0)
|
||||
var (parsed, firstDevice) = ExtractTarget(opts);
|
||||
if (parsed is null || string.IsNullOrWhiteSpace(parsed.Gateway) || parsed.Port <= 0)
|
||||
return new(false, "Config has no host/port to probe.", null);
|
||||
|
||||
var host = parsed.Gateway;
|
||||
var port = parsed.Port;
|
||||
|
||||
// Phase 1: bare TCP preflight — fast rejection for unreachable hosts.
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await socket.ConnectAsync(host, port, ct);
|
||||
sw.Stop();
|
||||
return new(true, null, sw.Elapsed);
|
||||
}
|
||||
catch (SocketException ex)
|
||||
{
|
||||
@@ -56,17 +78,104 @@ public sealed class AbLegacyDriverProbe : IDriverProbe
|
||||
{
|
||||
return new(false, ex.Message, null);
|
||||
}
|
||||
|
||||
// Phase 2: PCCC session via libplctag LibplctagLegacyTagRuntime.InitializeAsync.
|
||||
// Open a real PCCC-over-EIP session to confirm the remote endpoint speaks PCCC, not just TCP.
|
||||
var profile = AbLegacyPlcFamilyProfile.ForFamily(firstDevice?.PlcFamily ?? AbLegacyPlcFamily.Slc500);
|
||||
|
||||
// Use the configured probe address (defaults to "S:0" — status file, first word).
|
||||
// S:0 is present on all PCCC PLCs that support the status file, so a "not found" response
|
||||
// still proves PCCC connectivity; a successful read is the happy path.
|
||||
var tagName = opts.Probe.ProbeAddress
|
||||
?? opts.Tags.FirstOrDefault(
|
||||
t => string.Equals(t.DeviceHostAddress, firstDevice?.HostAddress, StringComparison.OrdinalIgnoreCase))
|
||||
?.Address
|
||||
?? opts.Tags.FirstOrDefault()?.Address
|
||||
?? "S:0";
|
||||
|
||||
var p = new AbLegacyTagCreateParams(
|
||||
Gateway: parsed.Gateway,
|
||||
Port: parsed.Port,
|
||||
CipPath: parsed.CipPath,
|
||||
LibplctagPlcAttribute: profile.LibplctagPlcAttribute,
|
||||
TagName: tagName,
|
||||
Timeout: timeout);
|
||||
|
||||
var rt = new LibplctagLegacyTagRuntime(p);
|
||||
try
|
||||
{
|
||||
await rt.InitializeAsync(ct);
|
||||
sw.Stop();
|
||||
|
||||
// InitializeAsync completed without throwing — either the tag was found (Ok) or
|
||||
// libplctag returned a non-exception status. Check via GetStatus().
|
||||
var statusCode = (Status)rt.GetStatus();
|
||||
if (IsReachableStatus(statusCode))
|
||||
{
|
||||
var msg = statusCode == Status.Ok
|
||||
? "PCCC session OK"
|
||||
: "PCCC session OK (controller reachable; probe tag not found)";
|
||||
return new(true, msg, sw.Elapsed);
|
||||
}
|
||||
|
||||
// Non-Ok but non-exception: a session/transport error surfaced in the status word.
|
||||
return new(false, $"Reachable at {host}:{port} but PCCC handshake failed: {statusCode}", null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
|
||||
}
|
||||
catch (LibPlcTagException ex)
|
||||
{
|
||||
// LibPlcTagException carries the Status; classify as reachable vs transport failure.
|
||||
if (IsReachableException(ex))
|
||||
return new(true, "PCCC session OK (controller reachable; probe tag not found)", sw.Elapsed);
|
||||
|
||||
return new(false, $"Reachable at {host}:{port} but PCCC handshake failed: {ex.Message}", null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new(false, $"Reachable at {host}:{port} but PCCC handshake failed: {ex.Message}", null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
rt.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static (string host, int port) ExtractTarget(AbLegacyDriverOptions opts)
|
||||
/// <summary>
|
||||
/// Returns <c>true</c> for libplctag statuses that indicate the PCCC controller answered
|
||||
/// but the probe tag name was not found or did not match — sufficient to confirm PCCC
|
||||
/// reachability. Returns <c>true</c> for <see cref="Status.Ok"/> (tag was found).
|
||||
/// </summary>
|
||||
private static bool IsReachableStatus(Status s) => s is
|
||||
Status.Ok or
|
||||
Status.ErrorNotFound or
|
||||
Status.ErrorNoMatch or
|
||||
Status.ErrorBadDevice;
|
||||
|
||||
/// <summary>
|
||||
/// Inspects a <see cref="LibPlcTagException"/> message to decide whether the error is a
|
||||
/// "controller answered PCCC but tag not found" condition (reachable) versus a session /
|
||||
/// transport failure (unreachable). Falls back to checking the exception message for
|
||||
/// known tag-not-found phrases used by the libplctag native library.
|
||||
/// </summary>
|
||||
private static bool IsReachableException(LibPlcTagException ex)
|
||||
{
|
||||
// libplctag wraps the Status in the message as "STATUS_NOT_FOUND", "STATUS_NO_MATCH", etc.
|
||||
var msg = ex.Message ?? string.Empty;
|
||||
return msg.Contains("NOT_FOUND", StringComparison.OrdinalIgnoreCase)
|
||||
|| msg.Contains("NO_MATCH", StringComparison.OrdinalIgnoreCase)
|
||||
|| msg.Contains("BAD_DEVICE", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static (AbLegacyHostAddress? parsed, AbLegacyDeviceOptions? device) ExtractTarget(AbLegacyDriverOptions opts)
|
||||
{
|
||||
// Parse the first device's ab:// host address to extract the gateway IP + EIP port.
|
||||
var firstDevice = opts.Devices.FirstOrDefault();
|
||||
if (firstDevice is null) return (string.Empty, 0);
|
||||
if (firstDevice is null) return (null, null);
|
||||
|
||||
var parsed = AbLegacyHostAddress.TryParse(firstDevice.HostAddress);
|
||||
if (parsed is null) return (string.Empty, 0);
|
||||
|
||||
return (parsed.Gateway, parsed.Port);
|
||||
return (parsed, firstDevice);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// Happy-path PCCC handshake is live-verify DEFERRED — no PLC5/SLC sim on the dev rig.
|
||||
// The libplctag code path (LibplctagLegacyTagRuntime.InitializeAsync + Status mapping) is
|
||||
// verified-by-proxy via the AbCip probe, which exercises the same libplctag native layer.
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="AbLegacyDriverProbe"/>. Covers the offline-determinable
|
||||
/// failure paths: invalid JSON, missing host/port, malformed host address, an unreachable
|
||||
/// (TCP-level rejected) target, and a black-hole target with a short timeout.
|
||||
/// The happy path (real PCCC session + <c>Ok=true</c>) requires a live PLC5/SLC
|
||||
/// simulator and is deferred — no such sim exists on the dev rig.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyDriverProbeTests
|
||||
{
|
||||
private static readonly AbLegacyDriverProbe Probe = new();
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 1. Invalid JSON
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Invalid JSON returns Ok=false with a message containing "invalid".</summary>
|
||||
[Fact]
|
||||
public async Task InvalidJson_Returns_OkFalse_WithInvalidMessage()
|
||||
{
|
||||
var result = await Probe.ProbeAsync(
|
||||
"not-valid-json{{{",
|
||||
TimeSpan.FromSeconds(3),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Message.ShouldNotBeNull();
|
||||
result.Message!.ToLowerInvariant().ShouldContain("invalid");
|
||||
result.Latency.ShouldBeNull();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 2. Config with no device / no host
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// A config with an empty Devices list returns Ok=false with a "no host/port" message.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task NoDevices_Returns_OkFalse_WithNoHostPortMessage()
|
||||
{
|
||||
var result = await Probe.ProbeAsync(
|
||||
"{\"devices\":[]}",
|
||||
TimeSpan.FromSeconds(3),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Message.ShouldNotBeNull();
|
||||
result.Message!.ShouldContain("no host/port");
|
||||
result.Latency.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A config whose first device carries a malformed host address (not ab://) returns
|
||||
/// Ok=false with a "no host/port" message because <see cref="AbLegacyHostAddress.TryParse"/>
|
||||
/// returns null for the address.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MalformedHostAddress_Returns_OkFalse_WithNoHostPortMessage()
|
||||
{
|
||||
// "not-an-ab-url" is not an ab:// URL — TryParse returns null.
|
||||
var result = await Probe.ProbeAsync(
|
||||
"{\"devices\":[{\"hostAddress\":\"not-an-ab-url\"}]}",
|
||||
TimeSpan.FromSeconds(3),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Message.ShouldNotBeNull();
|
||||
result.Message!.ShouldContain("no host/port");
|
||||
result.Latency.ShouldBeNull();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3. Unreachable target — TCP connect fails at Phase 1
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// A closed port causes <c>ConnectAsync</c> to throw <see cref="SocketException"/>;
|
||||
/// the probe must return Ok=false with a "Connect failed" message.
|
||||
/// The port is reserved then released so the OS refuses the connection immediately
|
||||
/// (no black-hole delay needed).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ClosedPort_Returns_OkFalse_WithConnectFailedMessage()
|
||||
{
|
||||
// Reserve an ephemeral port then release it so the port is definitely closed.
|
||||
var reserved = new TcpListener(IPAddress.Loopback, 0);
|
||||
reserved.Start();
|
||||
var port = ((IPEndPoint)reserved.LocalEndpoint).Port;
|
||||
reserved.Stop();
|
||||
|
||||
// Use a short timeout — we only need Phase 1 to fail fast.
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(
|
||||
TestContext.Current.CancellationToken);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(5));
|
||||
|
||||
var configJson = $"{{\"devices\":[{{\"hostAddress\":\"ab://127.0.0.1:{port}/1,0\"}}]}}";
|
||||
var result = await Probe.ProbeAsync(
|
||||
configJson,
|
||||
TimeSpan.FromSeconds(3),
|
||||
cts.Token);
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Message.ShouldNotBeNull();
|
||||
// Phase 1 SocketException → "Connect failed: <SocketErrorCode>"
|
||||
result.Message!.ShouldContain("Connect failed");
|
||||
result.Latency.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A target on a non-routable IP (192.0.2.1 — TEST-NET-1, RFC 5737) causes the
|
||||
/// probe to time out at Phase 1 when given a very short cancellation window.
|
||||
/// The probe must return Ok=false with a "timed out" or "Connect failed" message.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task BlackHoleTarget_TimedOut_Returns_OkFalse_WithTimedOutOrConnectFailedMessage()
|
||||
{
|
||||
// 192.0.2.1 is TEST-NET-1 (RFC 5737) — reserved, non-routable, connection black-holes.
|
||||
// Use a 1-second cancellation so the test stays fast on any network.
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(
|
||||
TestContext.Current.CancellationToken);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(1));
|
||||
|
||||
var configJson = "{\"devices\":[{\"hostAddress\":\"ab://192.0.2.1/1,0\"}]}";
|
||||
var result = await Probe.ProbeAsync(
|
||||
configJson,
|
||||
TimeSpan.FromSeconds(1),
|
||||
cts.Token);
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Message.ShouldNotBeNull();
|
||||
// Either "timed out" (OperationCanceledException) or "Connect failed" (EHOSTUNREACH on
|
||||
// some network stacks) — both are acceptable "not reachable" outcomes.
|
||||
var lower = result.Message!.ToLowerInvariant();
|
||||
(lower.Contains("timed out") || lower.Contains("connect failed")).ShouldBeTrue(
|
||||
$"Expected 'timed out' or 'Connect failed' but got: {result.Message}");
|
||||
result.Latency.ShouldBeNull();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user