using System.Diagnostics;
using System.Net.Sockets;
using System.Text.Json;
using System.Text.Json.Serialization;
using Opc.Ua;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
///
/// Probe for the -shaped driver config. Parses the
/// first endpoint URL (from or the
/// convenience fallback), performs a
/// TCP-connect preflight to detect closed ports quickly, then executes a real OPC UA
/// GetEndpoints discovery handshake (mirroring the driver's
/// SelectMatchingEndpointAsync) to confirm the remote process is actually an OPC UA
/// server. No session is opened and no authentication is required.
///
public sealed class OpcUaClientDriverProbe : IDriverProbe
{
// Kept identical to OpcUaClientDriverFactoryExtensions.JsonOptions so the probe and the
// factory parse a given DriverConfig the same way. The JsonStringEnumConverter lets
// enum-valued knobs be authored as their string names.
private static readonly JsonSerializerOptions _opts = new()
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
Converters = { new JsonStringEnumConverter() },
};
///
public string DriverType => "OpcUaClient";
///
public async Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
{
OpcUaClientDriverOptions? 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, endpointUrl) = ExtractTarget(opts);
if (string.IsNullOrWhiteSpace(host) || port <= 0)
return new(false, "Config has no host/port to probe.", null);
// Bound the whole probe (both phases) by the caller timeout, independent of `ct` — a
// stalled OPC UA endpoint that accepts TCP but never answers GetEndpoints must not block
// the probe when the caller passes CancellationToken.None.
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(timeout);
// --- TCP preflight: fast-fail for closed ports / unreachable hosts ---
var sw = Stopwatch.StartNew();
try
{
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await socket.ConnectAsync(host, port, cts.Token);
}
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);
}
// --- OPC UA discovery handshake: confirm the remote is an OPC UA server ---
// Mirrors OpcUaClientDriver.SelectMatchingEndpointAsync exactly:
// DiscoveryClient.CreateAsync(appConfig, new Uri(endpointUrl), DiagnosticsMasks.None, ct)
// GetEndpoints needs no session, no application certificate, and no authentication.
try
{
var appConfig = BuildMinimalAppConfig();
using var client = await DiscoveryClient.CreateAsync(
appConfig, new Uri(endpointUrl), DiagnosticsMasks.None, cts.Token).ConfigureAwait(false);
var endpoints = await client.GetEndpointsAsync(null, cts.Token).ConfigureAwait(false);
sw.Stop();
if (endpoints is { Count: > 0 })
return new(true, $"OPC UA: {endpoints.Count} endpoint(s)", sw.Elapsed);
return new(false,
$"Reachable at {host}:{port} but OPC UA handshake failed: server published 0 endpoints",
null);
}
catch (OperationCanceledException)
{
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
}
catch (Exception ex)
{
return new(false,
$"Reachable at {host}:{port} but OPC UA handshake failed: {ex.Message}",
null);
}
}
///
/// Builds a minimal suitable for unauthenticated
/// discovery (GetEndpoints). No PKI stores, no session certificate — discovery traffic
/// is plain-text and does not require a client certificate.
///
private static ApplicationConfiguration BuildMinimalAppConfig() =>
new()
{
ApplicationName = "OtOpcUa.Probe",
ApplicationType = ApplicationType.Client,
SecurityConfiguration = new SecurityConfiguration
{
AutoAcceptUntrustedCertificates = true,
},
TransportQuotas = new TransportQuotas(),
ClientConfiguration = new ClientConfiguration(),
DisableHiResClock = true,
};
private static (string host, int port, string endpointUrl) ExtractTarget(OpcUaClientDriverOptions opts)
{
// EndpointUrls wins over the convenience EndpointUrl when both are set.
var endpointUrl = opts.EndpointUrls.FirstOrDefault()
?? (string.IsNullOrWhiteSpace(opts.EndpointUrl) ? null : opts.EndpointUrl);
if (endpointUrl is null) return (string.Empty, 0, string.Empty);
// Parse as a URI — opc.tcp://host:port is a valid URI.
if (!Uri.TryCreate(endpointUrl, UriKind.Absolute, out var uri))
return (string.Empty, 0, string.Empty);
var host = uri.Host;
var port = uri.IsDefaultPort ? 4840 : uri.Port;
return (host, port, endpointUrl);
}
}