feat(drivers): TCP-connect IDriverProbe for all 9 driver types

Cheap-and-fast probe: open TCP socket to the configured endpoint,
close immediately. Surfaces SocketError on failure, latency on
success, "timed out" on caller cancel. Sufficient for the AdminUI
Test Connect "can we reach the host?" question. Richer protocol-
level probes (OPC UA session open, FOCAS handshake, gRPC ping)
are a documented follow-up. Each probe registered as
AddSingleton<IDriverProbe, X> in DriverFactoryBootstrap so they
flow through DI into AdminOperationsActor.

Historian.Wonderware returns a clean "TCP probe not applicable"
result because it communicates over a Windows named pipe, not TCP.
Also adds OpcUaClient + Historian.Wonderware.Client project
references to Host.csproj (both were missing from the driver
ItemGroup).
This commit is contained in:
Joseph Doherty
2026-05-28 10:53:42 -04:00
parent f3f328c25c
commit c19d124e89
11 changed files with 661 additions and 0 deletions
@@ -0,0 +1,84 @@
using System.Diagnostics;
using System.Net.Sockets;
using System.Text.Json;
using System.Text.Json.Serialization;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// <summary>
/// Cheap TCP-connect probe for the <see cref="TwinCATDriverOptions"/>-shaped driver config.
/// Opens a socket to the first device's AMS router host (first four octets of the AMS Net ID)
/// on the AMS port from the address 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 ADS bytes — a richer ADS-state probe is a documented follow-up.
/// </summary>
/// <remarks>
/// AMS Net ID format is six dot-separated octets (e.g. <c>192.168.1.10.1.1</c>); the first
/// four are typically the host IPv4 address by Beckhoff convention, but the AMS router
/// resolves the real IP route server-side. The probe uses the first-four-octet heuristic
/// which is reliable for the overwhelming majority of production deployments.
/// </remarks>
public sealed class TwinCATDriverProbe : IDriverProbe
{
private static readonly JsonSerializerOptions _opts = new()
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
};
/// <inheritdoc />
public string DriverType => "TwinCAT";
/// <inheritdoc />
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
{
TwinCATDriverOptions? opts;
try { opts = JsonSerializer.Deserialize<TwinCATDriverOptions>(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();
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)
{
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);
}
}
private static (string host, int port) ExtractTarget(TwinCATDriverOptions opts)
{
// Parse the first device's ads:// address. AMS Net ID is six-octet; by Beckhoff
// convention the first four octets are the host IPv4. Extract those as the TCP target.
var firstDevice = opts.Devices.FirstOrDefault();
if (firstDevice is null) return (string.Empty, 0);
var parsed = TwinCATAmsAddress.TryParse(firstDevice.HostAddress);
if (parsed is null) return (string.Empty, 0);
// NetId = "a.b.c.d.e.f" — take the first 4 octets as the host IP.
var parts = parsed.NetId.Split('.');
if (parts.Length < 4) return (string.Empty, 0);
var hostIp = string.Join('.', parts[0], parts[1], parts[2], parts[3]);
return (hostIp, parsed.Port);
}
}