193 lines
8.9 KiB
C#
193 lines
8.9 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Two-phase Test-Connect probe for the <see cref="FocasDriverOptions"/>-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
|
|
/// <c>cnc_allclibhndl3(host, port, timeoutSec, out handle)</c> and immediately frees it with
|
|
/// <c>cnc_freelibhndl</c>. A handle that allocates (<c>EW_OK</c>) confirms the remote endpoint
|
|
/// is a real FOCAS CNC, not just a TCP listener.
|
|
/// <para>
|
|
/// The P/Invoke is issued directly (it does NOT route through
|
|
/// <see cref="UnimplementedFocasClientFactory"/>, whose <c>EnsureUsable()</c> 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
|
|
/// (<c>ct</c> + <c>CancelAfter(timeout)</c>) — the probe always returns within the timeout
|
|
/// budget even if FWLIB hangs.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Degrade guard (the crux).</b> On a host without the FWLIB native library — this dev box
|
|
/// (macOS) and the Linux CI containers — the <c>cnc_allclibhndl3</c> P/Invoke fails to bind
|
|
/// and throws <see cref="DllNotFoundException"/> (or a related load failure:
|
|
/// <see cref="TypeInitializationException"/>, <see cref="NotSupportedException"/>,
|
|
/// <see cref="BadImageFormatException"/>, <see cref="EntryPointNotFoundException"/>). Those are
|
|
/// caught and the probe falls back to <c>Ok=true</c> 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).
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class FocasDriverProbe : IDriverProbe
|
|
{
|
|
/// <summary>FANUC FWLIB return code for success (<c>EW_OK</c>).</summary>
|
|
private const short EwOk = 0;
|
|
|
|
private static readonly JsonSerializerOptions _opts = new()
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
|
};
|
|
|
|
/// <inheritdoc />
|
|
public string DriverType => "FOCAS";
|
|
|
|
/// <inheritdoc />
|
|
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
|
|
{
|
|
FocasDriverOptions? opts;
|
|
try { opts = JsonSerializer.Deserialize<FocasDriverOptions>(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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Attempts the FWLIB handle handshake against <paramref name="host"/>/<paramref name="port"/>.
|
|
/// On success the handle is freed immediately. Returns <c>degraded=true</c> when the native
|
|
/// library cannot be loaded (FWLIB absent — the dev/CI reality); otherwise
|
|
/// <c>degraded=false</c> with the FWLIB return code (<c>EW_OK</c> = handle allocated).
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Minimal P/Invoke surface for the two FANUC FWLIB entry points the probe needs:
|
|
/// <c>cnc_allclibhndl3</c> to allocate a CNC handle against a host/port, and
|
|
/// <c>cnc_freelibhndl</c> to release it. The native library (<c>fwlib32.dll</c> /
|
|
/// <c>fwlib64.dll</c> on Windows, <c>libfwlib32.so</c> 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 <see cref="DllNotFoundException"/> — caught by the
|
|
/// probe's degrade guard.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|