diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs
index 754d9b9f..869588f9 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.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.AbCip.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
///
-/// 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 CIP 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 EIP session and perform a CIP Forward Open —
+/// confirming the remote endpoint actually speaks EtherNet/IP CIP, not just accepts TCP.
+/// A device that accepts the TCP connection but is not a CIP controller (wrong CIP path,
+/// non-CIP 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 CIP session but could not resolve the probe tag
+/// (, ,
+/// ) are treated as "reachable — Ok = true" because
+/// a non-existent tag name proves CIP 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 AbCipDriverProbe : IDriverProbe
{
@@ -32,17 +52,19 @@ public sealed class AbCipDriverProbe : 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,106 @@ public sealed class AbCipDriverProbe : IDriverProbe
{
return new(false, ex.Message, null);
}
+
+ // Phase 2: CIP session via libplctag Tag.InitializeAsync. Open a real EIP session +
+ // Forward Open to confirm the remote endpoint speaks CIP, not just TCP.
+ var profile = AbCipPlcFamilyProfile.ForFamily(firstDevice?.PlcFamily ?? AbCipPlcFamily.ControlLogix);
+
+ // Prefer the first declared tag path as the probe tag name (it must exist on the PLC).
+ // Fall back to a benign system attribute that is present on all ControlLogix/CompactLogix
+ // controllers (@raw_cpu_type); still returns ErrorNotFound on Micro800 / MicroLogix, which
+ // is sufficient to confirm CIP reachability.
+ var tagName = opts.Tags.FirstOrDefault(
+ t => string.Equals(t.DeviceHostAddress, firstDevice?.HostAddress, StringComparison.OrdinalIgnoreCase))
+ ?.TagPath
+ ?? opts.Tags.FirstOrDefault()?.TagPath
+ ?? "@raw_cpu_type";
+
+ var p = new AbCipTagCreateParams(
+ Gateway: parsed.Gateway,
+ Port: parsed.Port,
+ CipPath: parsed.CipPath,
+ LibplctagPlcAttribute: profile.LibplctagPlcAttribute,
+ TagName: tagName,
+ Timeout: timeout,
+ AllowPacking: firstDevice?.AllowPacking ?? profile.SupportsRequestPacking,
+ ConnectionSize: firstDevice?.ConnectionSize ?? profile.DefaultConnectionSize);
+
+ var rt = new LibplctagTagRuntime(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
+ ? "CIP session OK"
+ : "CIP 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 CIP 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, "CIP session OK (controller reachable; probe tag not found)", sw.Elapsed);
+
+ return new(false, $"Reachable at {host}:{port} but CIP handshake failed: {ex.Message}", null);
+ }
+ catch (Exception ex)
+ {
+ return new(false, $"Reachable at {host}:{port} but CIP handshake failed: {ex.Message}", null);
+ }
+ finally
+ {
+ rt.Dispose();
+ }
}
- private static (string host, int port) ExtractTarget(AbCipDriverOptions opts)
+ ///
+ /// Returns true for libplctag statuses that indicate the CIP controller answered
+ /// but the probe tag name was not found or did not match — sufficient to confirm CIP
+ /// 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 CIP 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 (AbCipHostAddress? parsed, AbCipDeviceOptions? device) ExtractTarget(AbCipDriverOptions 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 = AbCipHostAddress.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.AbCip.Tests/AbCipDriverProbeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverProbeTests.cs
new file mode 100644
index 00000000..c4dd326a
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverProbeTests.cs
@@ -0,0 +1,147 @@
+using System.Net;
+using System.Net.Sockets;
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
+
+///
+/// Unit tests for . Covers the three offline-determinable
+/// failure paths: invalid JSON, missing host/port, and an unreachable (TCP-level rejected)
+/// target. The happy path (real CIP EIP session + Ok=true) and the
+/// "controller reachable but probe tag not found" path require a live CIP controller or
+/// simulator and are covered in integration tests against the AB-CIP Docker fixture
+/// (10.100.0.35:44818).
+///
+[Trait("Category", "Unit")]
+public sealed class AbCipDriverProbeTests
+{
+ private static readonly AbCipDriverProbe 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" message.
+ ///
+ [Fact]
+ public async Task BlackHoleTarget_TimedOut_Returns_OkFalse_WithTimedOutMessage()
+ {
+ // 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();
+ }
+}