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(); + } +}