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; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS; /// /// 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, UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, }; /// public string DriverType => "FOCAS"; /// public async Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) { FocasDriverOptions? opts; try { opts = JsonSerializer.Deserialize(configJson, _opts); } 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) 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); } catch (SocketException ex) { return new(false, $"Connect failed: {ex.SocketErrorCode}", null); } catch (OperationCanceledException) { return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null); } catch (Exception ex) { 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) { // The caller cancelled, or the Task.Run was cancelled before the native call started. // (A native cnc_allclibhndl3 that is already running is bounded by the timeoutSeconds // argument passed into it, not by handshakeCts — see TryAllocateAndFreeHandle.) 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) { // Parse the first device's focas:// address to extract host + port. var firstDevice = opts.Devices.FirstOrDefault(); if (firstDevice is null) return (string.Empty, 0); var parsed = FocasHostAddress.TryParse(firstDevice.HostAddress); if (parsed is null) return (string.Empty, 0); 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); } }