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 3a178788..f2f0715e 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs
@@ -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;
///
-/// Cheap TCP-connect probe for the -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 -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
+/// 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 Ok = false with a "handshake failed" message instead of
+/// a false-positive green tick.
+///
+/// Status-code discrimination: libplctag statuses that indicate the controller answered
+/// the PCCC session but could not resolve the probe tag
+/// (, ,
+/// ) are treated as "reachable — Ok = true" because
+/// a non-existent tag name proves PCCC connectivity. Statuses that indicate a transport or
+/// session failure (, ,
+/// , ,
+/// , ,
+/// , ,
+/// , ,
+/// , ) are treated as
+/// "handshake failed — Ok = false". All other error statuses default to
+/// "handshake failed" (conservative).
+///
///
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)
+ ///
+ /// Returns true 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 true for (tag was found).
+ ///
+ private static bool IsReachableStatus(Status s) => s is
+ Status.Ok or
+ Status.ErrorNotFound or
+ Status.ErrorNoMatch or
+ Status.ErrorBadDevice;
+
+ ///
+ /// Inspects a 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.
+ ///
+ 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);
}
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDriverProbeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDriverProbeTests.cs
new file mode 100644
index 00000000..1cf1591b
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDriverProbeTests.cs
@@ -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;
+
+///
+/// Unit tests for . 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 + Ok=true) requires a live PLC5/SLC
+/// simulator and is deferred — no such sim exists on the dev rig.
+///
+[Trait("Category", "Unit")]
+public sealed class AbLegacyDriverProbeTests
+{
+ private static readonly AbLegacyDriverProbe Probe = new();
+
+ // -------------------------------------------------------------------------
+ // 1. Invalid JSON
+ // -------------------------------------------------------------------------
+
+ /// Invalid JSON returns Ok=false with a message containing "invalid".
+ [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
+ // -------------------------------------------------------------------------
+
+ ///
+ /// A config with an empty Devices list returns Ok=false with a "no host/port" message.
+ ///
+ [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();
+ }
+
+ ///
+ /// A config whose first device carries a malformed host address (not ab://) returns
+ /// Ok=false with a "no host/port" message because
+ /// returns null for the address.
+ ///
+ [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
+ // -------------------------------------------------------------------------
+
+ ///
+ /// A closed port causes ConnectAsync to throw ;
+ /// 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).
+ ///
+ [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: "
+ result.Message!.ShouldContain("Connect failed");
+ result.Latency.ShouldBeNull();
+ }
+
+ ///
+ /// 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.
+ ///
+ [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();
+ }
+}