diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverProbe.cs
index e7aa2b01..865b6ab5 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverProbe.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverProbe.cs
@@ -1,5 +1,6 @@
using System.Diagnostics;
using System.Net.Sockets;
+using System.Runtime.InteropServices;
using System.Text.Json;
using System.Text.Json.Serialization;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -7,14 +8,38 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
///
-/// Cheap TCP-connect probe for the -shaped driver config.
-/// Opens a socket to the first device's FOCAS Ethernet address + 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 FOCAS/2 bytes —
-/// a richer FOCAS handshake (cnc_allclibhndl3) 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 FOCAS Ethernet address + port to quickly
+/// reject unreachable targets (preserves the original "Connect failed" / "timed out"
+/// messages). Phase 2: attempts the FANUC FWLIB handle handshake — allocates a CNC handle via
+/// cnc_allclibhndl3(host, port, timeoutSec, out handle) and immediately frees it with
+/// cnc_freelibhndl. A handle that allocates (EW_OK) confirms the remote endpoint
+/// is a real FOCAS CNC, not just a TCP listener.
+///
+/// The P/Invoke is issued directly (it does NOT route through
+/// , whose EnsureUsable() throws by
+/// design) so the handshake works on a real Windows+FWLIB host and degrades everywhere else.
+/// The synchronous native call can block, so it runs on a worker bounded by a linked CTS
+/// (ct + CancelAfter(timeout)) — the probe always returns within the timeout
+/// budget even if FWLIB hangs.
+///
+///
+/// Degrade guard (the crux). On a host without the FWLIB native library — this dev box
+/// (macOS) and the Linux CI containers — the cnc_allclibhndl3 P/Invoke fails to bind
+/// and throws (or a related load failure:
+/// , ,
+/// , ). Those are
+/// caught and the probe falls back to Ok=true with a "FWLIB absent — TCP reachability
+/// only" note, so the probe is never worse than the original TCP-only behaviour on FWLIB-less
+/// hosts. The happy path and the FWLIB-present CNC-error path are live-verify deferred (no CNC
+/// and no FWLIB on the rig).
+///
///
public sealed class FocasDriverProbe : IDriverProbe
{
+ /// FANUC FWLIB return code for success (EW_OK).
+ private const short EwOk = 0;
+
private static readonly JsonSerializerOptions _opts = new()
{
PropertyNameCaseInsensitive = true,
@@ -37,12 +62,12 @@ public sealed class FocasDriverProbe : IDriverProbe
return new(false, "Config has no host/port to probe.", null);
var sw = Stopwatch.StartNew();
+
+ // Phase 1: bare TCP preflight — fast rejection for unreachable hosts. Messages unchanged.
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,6 +81,75 @@ public sealed class FocasDriverProbe : IDriverProbe
{
return new(false, ex.Message, null);
}
+
+ // Phase 2: FOCAS handle handshake via cnc_allclibhndl3. The native call is synchronous and
+ // can block, so run it on a worker bounded by a linked CTS = ct + CancelAfter(timeout).
+ using var handshakeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ var budget = timeout > TimeSpan.Zero ? timeout : TimeSpan.FromSeconds(1);
+ handshakeCts.CancelAfter(budget);
+
+ try
+ {
+ var (degraded, rc) = await Task.Run(
+ () => TryAllocateAndFreeHandle(host, port, budget),
+ handshakeCts.Token);
+
+ sw.Stop();
+
+ if (degraded)
+ {
+ // FWLIB absent / cannot load — never worse than the original TCP-only probe.
+ return new(
+ true,
+ $"Reachable at {host}:{port} (FOCAS handshake unavailable on this host — " +
+ "FWLIB absent, TCP reachability only)",
+ sw.Elapsed);
+ }
+
+ if (rc == EwOk)
+ return new(true, "FOCAS handle OK", sw.Elapsed);
+
+ // FWLIB present but the remote returned an error — reachable TCP but not a CNC.
+ return new(false, $"Reachable at {host}:{port} but FOCAS handshake failed: focas_rc={rc}", null);
+ }
+ catch (OperationCanceledException)
+ {
+ // ct cancelled or the native handshake exceeded the timeout budget.
+ return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
+ }
+ }
+
+ ///
+ /// Attempts the FWLIB handle handshake against /.
+ /// On success the handle is freed immediately. Returns degraded=true when the native
+ /// library cannot be loaded (FWLIB absent — the dev/CI reality); otherwise
+ /// degraded=false with the FWLIB return code (EW_OK = handle allocated).
+ ///
+ private static (bool degraded, short rc) TryAllocateAndFreeHandle(string host, int port, TimeSpan timeout)
+ {
+ var timeoutSeconds = (int)Math.Ceiling(timeout.TotalSeconds);
+ if (timeoutSeconds <= 0) timeoutSeconds = 1;
+
+ ushort handle = 0;
+ try
+ {
+ var rc = NativeFwlib.cnc_allclibhndl3(host, (ushort)port, timeoutSeconds, out handle);
+ return (degraded: false, rc);
+ }
+ catch (DllNotFoundException) { return (degraded: true, rc: default); }
+ catch (TypeInitializationException) { return (degraded: true, rc: default); }
+ catch (NotSupportedException) { return (degraded: true, rc: default); }
+ catch (BadImageFormatException) { return (degraded: true, rc: default); }
+ catch (EntryPointNotFoundException) { return (degraded: true, rc: default); }
+ finally
+ {
+ // Best-effort free if a handle was actually allocated (incl. after a timeout race).
+ if (handle != 0)
+ {
+ try { NativeFwlib.cnc_freelibhndl(handle); }
+ catch { /* best-effort — never let teardown hide the probe result */ }
+ }
+ }
}
private static (string host, int port) ExtractTarget(FocasDriverOptions opts)
@@ -69,4 +163,28 @@ public sealed class FocasDriverProbe : IDriverProbe
return (parsed.Host, parsed.Port);
}
+
+ ///
+ /// Minimal P/Invoke surface for the two FANUC FWLIB entry points the probe needs:
+ /// cnc_allclibhndl3 to allocate a CNC handle against a host/port, and
+ /// cnc_freelibhndl to release it. The native library (fwlib32.dll /
+ /// fwlib64.dll on Windows, libfwlib32.so on Linux) is only present on a host
+ /// with the FANUC FWLIB redistributable installed. On every other host the JIT fails to
+ /// bind these entry points and throws — caught by the
+ /// probe's degrade guard.
+ ///
+ private static class NativeFwlib
+ {
+ private const string Library = "fwlib32";
+
+ [DllImport(Library, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
+ internal static extern short cnc_allclibhndl3(
+ [MarshalAs(UnmanagedType.LPStr)] string ipaddr,
+ ushort port,
+ int timeout,
+ out ushort handle);
+
+ [DllImport(Library, CallingConvention = CallingConvention.Cdecl)]
+ internal static extern short cnc_freelibhndl(ushort handle);
+ }
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDriverProbeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDriverProbeTests.cs
new file mode 100644
index 00000000..626925e7
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDriverProbeTests.cs
@@ -0,0 +1,184 @@
+using System.Net;
+using System.Net.Sockets;
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
+
+///
+/// Unit tests for . Covers the offline-determinable failure
+/// paths (invalid JSON, missing host/port, unreachable closed port) plus the degrade path:
+/// on a host with no FANUC FWLIB native library present (this dev box / CI Linux containers),
+/// the cnc_allclibhndl3 P/Invoke throws at JIT bind
+/// time, so a TCP-reachable target must still report Ok=true with a "FWLIB absent"
+/// note — never worse than the pre-Phase-5 TCP-only probe.
+///
+/// Live-verify DEFERRED. The happy path (a real CNC answers cnc_allclibhndl3
+/// with EW_OK → "FOCAS handle OK") and the CNC-error path (FWLIB present but the
+/// remote returns e.g. EW_SOCKET/EW_PROTOCOL → "FOCAS handshake failed:
+/// focas_rc=...") cannot run on this rig: there is neither a FANUC CNC nor the FWLIB native
+/// library available. Those two paths are verified manually against a real Windows+FWLIB host.
+///
+///
+[Trait("Category", "Unit")]
+public sealed class FocasDriverProbeTests
+{
+ private static readonly FocasDriverProbe 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 focas://) returns
+ /// Ok=false with a "no host/port" message because
+ /// returns null for the address.
+ ///
+ [Fact]
+ public async Task MalformedHostAddress_Returns_OkFalse_WithNoHostPortMessage()
+ {
+ // "not-a-focas-url" is not a focas:// URL — TryParse returns null.
+ var result = await Probe.ProbeAsync(
+ "{\"devices\":[{\"hostAddress\":\"not-a-focas-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 preflight
+ // -------------------------------------------------------------------------
+
+ ///
+ /// 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();
+
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(
+ TestContext.Current.CancellationToken);
+ cts.CancelAfter(TimeSpan.FromSeconds(5));
+
+ var configJson = $"{{\"devices\":[{{\"hostAddress\":\"focas://127.0.0.1:{port}\"}}]}}";
+ var result = await Probe.ProbeAsync(
+ configJson,
+ TimeSpan.FromSeconds(3),
+ cts.Token);
+
+ result.Ok.ShouldBeFalse();
+ result.Message.ShouldNotBeNull();
+ result.Message!.ShouldContain("Connect failed");
+ result.Latency.ShouldBeNull();
+ }
+
+ // -------------------------------------------------------------------------
+ // 4. Degrade path — TCP reachable, FWLIB absent (the key test)
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Against an in-process that accepts the connection, the TCP
+ /// preflight succeeds. On this box the FANUC FWLIB native library is absent, so the
+ /// cnc_allclibhndl3 P/Invoke throws (or a
+ /// related load failure). The probe MUST degrade gracefully — return Ok=true with
+ /// a "FWLIB absent ... TCP reachability only" note — proving no regression versus the
+ /// pre-Phase-5 TCP-only probe on FWLIB-less hosts.
+ ///
+ [Fact]
+ public async Task TcpReachable_FwlibAbsent_Degrades_To_OkTrue_WithReachabilityNote()
+ {
+ // Accept-only listener: completes the TCP handshake but speaks no FOCAS bytes.
+ var listener = new TcpListener(IPAddress.Loopback, 0);
+ listener.Start();
+ var port = ((IPEndPoint)listener.LocalEndpoint).Port;
+ // Keep accepting so the connect always completes; ignore the accepted socket.
+ _ = AcceptLoopAsync(listener, TestContext.Current.CancellationToken);
+
+ try
+ {
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(
+ TestContext.Current.CancellationToken);
+ cts.CancelAfter(TimeSpan.FromSeconds(5));
+
+ var configJson = $"{{\"devices\":[{{\"hostAddress\":\"focas://127.0.0.1:{port}\"}}]}}";
+ var result = await Probe.ProbeAsync(
+ configJson,
+ TimeSpan.FromSeconds(3),
+ cts.Token);
+
+ // No FWLIB here → degrade, never worse than TCP-only.
+ result.Ok.ShouldBeTrue(
+ $"Expected degrade to Ok=true on an FWLIB-less host but got: {result.Message}");
+ result.Message.ShouldNotBeNull();
+ result.Message!.ShouldContain("FWLIB absent");
+ result.Message!.ShouldContain("TCP reachability only");
+ result.Latency.ShouldNotBeNull();
+ }
+ finally
+ {
+ listener.Stop();
+ }
+ }
+
+ private static async Task AcceptLoopAsync(TcpListener listener, CancellationToken ct)
+ {
+ try
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ var socket = await listener.AcceptSocketAsync(ct);
+ // Drop the connection immediately — we only need the TCP handshake to complete.
+ socket.Dispose();
+ }
+ }
+ catch
+ {
+ // Listener stopped / token cancelled — expected at test teardown.
+ }
+ }
+}