From c19d124e89516e9a38bee9cae8c14ab8d772cbe3 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 28 May 2026 10:53:42 -0400 Subject: [PATCH] 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 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). --- .../AbCipDriverProbe.cs | 72 ++++++++++++++++ .../AbLegacyDriverProbe.cs | 72 ++++++++++++++++ .../FocasDriverProbe.cs | 72 ++++++++++++++++ .../GalaxyDriverProbe.cs | 85 +++++++++++++++++++ .../WonderwareHistorianDriverProbe.cs | 47 ++++++++++ .../ModbusDriverProbe.cs | 63 ++++++++++++++ .../OpcUaClientDriverProbe.cs | 78 +++++++++++++++++ .../S7DriverProbe.cs | 63 ++++++++++++++ .../TwinCATDriverProbe.cs | 84 ++++++++++++++++++ .../Drivers/DriverFactoryBootstrap.cs | 23 +++++ .../ZB.MOM.WW.OtOpcUa.Host.csproj | 2 + 11 files changed, 661 insertions(+) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverProbe.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriverProbe.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianDriverProbe.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverProbe.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverProbe.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverProbe.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverProbe.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs new file mode 100644 index 00000000..754d9b9f --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs @@ -0,0 +1,72 @@ +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.AbCip; + +/// +/// Cheap TCP-connect probe for the -shaped driver config. +/// Opens a socket to the first device's gateway host + EtherNet/IP 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 CIP bytes — +/// a richer EIP session-open probe is a documented follow-up. +/// +public sealed class AbCipDriverProbe : IDriverProbe +{ + private static readonly JsonSerializerOptions _opts = new() + { + PropertyNameCaseInsensitive = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + }; + + /// + public string DriverType => "AbCip"; + + /// + public async Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) + { + AbCipDriverOptions? 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(); + 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(AbCipDriverOptions opts) + { + // Parse the first device's ab:// host address to extract the gateway IP + EIP port. + var firstDevice = opts.Devices.FirstOrDefault(); + if (firstDevice is null) return (string.Empty, 0); + + var parsed = AbCipHostAddress.TryParse(firstDevice.HostAddress); + if (parsed is null) return (string.Empty, 0); + + return (parsed.Gateway, parsed.Port); + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs new file mode 100644 index 00000000..3a178788 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs @@ -0,0 +1,72 @@ +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.AbLegacy; + +/// +/// Cheap TCP-connect probe for the -shaped driver config. +/// Opens a socket to the first device's gateway host + EtherNet/IP 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 PCCC bytes — +/// a richer EIP session-open probe is a documented follow-up. +/// +public sealed class AbLegacyDriverProbe : IDriverProbe +{ + private static readonly JsonSerializerOptions _opts = new() + { + PropertyNameCaseInsensitive = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + }; + + /// + public string DriverType => "AbLegacy"; + + /// + public async Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) + { + AbLegacyDriverOptions? 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(); + 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(AbLegacyDriverOptions opts) + { + // Parse the first device's ab:// host address to extract the gateway IP + EIP port. + var firstDevice = opts.Devices.FirstOrDefault(); + if (firstDevice is null) return (string.Empty, 0); + + var parsed = AbLegacyHostAddress.TryParse(firstDevice.HostAddress); + if (parsed is null) return (string.Empty, 0); + + return (parsed.Gateway, parsed.Port); + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverProbe.cs new file mode 100644 index 00000000..e7aa2b01 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverProbe.cs @@ -0,0 +1,72 @@ +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.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. +/// +public sealed class FocasDriverProbe : IDriverProbe +{ + 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(); + 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(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); + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriverProbe.cs new file mode 100644 index 00000000..202259a4 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriverProbe.cs @@ -0,0 +1,85 @@ +using System.Diagnostics; +using System.Net.Sockets; +using System.Text.Json; +using System.Text.Json.Serialization; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy; + +/// +/// Cheap TCP-connect probe for the -shaped driver config. +/// Parses the Gateway.Endpoint gRPC endpoint (e.g. http://host:5001 or +/// host:5001), opens a socket 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 gRPC frames — a richer gRPC ping probe is a +/// documented follow-up. +/// +public sealed class GalaxyDriverProbe : IDriverProbe +{ + private static readonly JsonSerializerOptions _opts = new() + { + PropertyNameCaseInsensitive = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + }; + + /// + public string DriverType => GalaxyDriverFactoryExtensions.DriverTypeName; // "GalaxyMxGateway" + + /// + public async Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) + { + GalaxyDriverOptions? 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(); + 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(GalaxyDriverOptions opts) + { + var endpoint = opts.Gateway.Endpoint; + if (string.IsNullOrWhiteSpace(endpoint)) return (string.Empty, 0); + + // Try absolute URI first (e.g. "http://hostname:5001" or "https://hostname:5001"). + if (Uri.TryCreate(endpoint, UriKind.Absolute, out var uri)) + { + var host = uri.Host; + // Uri.Port is -1 when not specified; default mxaccessgw port is 5001. + var port = uri.Port > 0 ? uri.Port : 5001; + return (host, port); + } + + // Fallback: treat as "host:port" (no scheme). + var colonIdx = endpoint.LastIndexOf(':'); + if (colonIdx > 0 && int.TryParse(endpoint[(colonIdx + 1)..], out var rawPort) && rawPort > 0) + return (endpoint[..colonIdx], rawPort); + + // No port found — return the whole string as host with default port. + return (endpoint, 5001); + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianDriverProbe.cs new file mode 100644 index 00000000..633c228c --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianDriverProbe.cs @@ -0,0 +1,47 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client; + +/// +/// Driver probe for the -shaped driver config. +/// The Wonderware Historian client communicates over a Windows named pipe (not a TCP socket), +/// so a cheap TCP-connect probe is not applicable for this transport. This probe always +/// returns a well-formed "not applicable" result so the AdminUI can display a meaningful +/// message instead of a red error. A full named-pipe connect + Hello-frame probe is a +/// documented follow-up. +/// +public sealed class WonderwareHistorianDriverProbe : IDriverProbe +{ + private static readonly JsonSerializerOptions _opts = new() + { + PropertyNameCaseInsensitive = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + }; + + /// + public string DriverType => "Historian.Wonderware"; + + /// + public Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) + { + // Validate the config JSON can at least be parsed — surface bad JSON immediately. + WonderwareHistorianClientOptions? opts; + try { opts = JsonSerializer.Deserialize(configJson, _opts); } + catch (Exception ex) + { + return Task.FromResult(new DriverProbeResult(false, $"Config JSON is invalid: {ex.Message}", null)); + } + if (opts is null) + return Task.FromResult(new DriverProbeResult(false, "Config JSON deserialized to null.", null)); + + // The Wonderware Historian sidecar communicates over a Windows named pipe; there is no + // TCP endpoint to connect to. A full pipe connect + Hello-frame probe is a follow-up. + return Task.FromResult(new DriverProbeResult( + false, + "TCP probe not applicable for this transport — the Historian sidecar uses a named pipe. " + + "A full named-pipe probe is a documented follow-up.", + null)); + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverProbe.cs new file mode 100644 index 00000000..f707ce22 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverProbe.cs @@ -0,0 +1,63 @@ +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.Modbus; + +/// +/// Cheap TCP-connect probe for the -shaped driver config. +/// Opens a socket to the configured endpoint 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 protocol bytes — richer per-driver +/// handshakes are a documented follow-up. +/// +public sealed class ModbusDriverProbe : IDriverProbe +{ + private static readonly JsonSerializerOptions _opts = new() + { + PropertyNameCaseInsensitive = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + }; + + /// + public string DriverType => "Modbus"; + + /// + public async Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) + { + ModbusDriverOptions? 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(); + 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(ModbusDriverOptions opts) + => (opts.Host, opts.Port); +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverProbe.cs new file mode 100644 index 00000000..c5f82b2f --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverProbe.cs @@ -0,0 +1,78 @@ +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.OpcUaClient; + +/// +/// Cheap TCP-connect probe for the -shaped driver config. +/// Parses the first endpoint URL (from or +/// the convenience fallback), opens a +/// socket to the OPC UA server host + 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 open an OPC UA session — a richer session-open probe is a +/// documented follow-up. +/// +public sealed class OpcUaClientDriverProbe : IDriverProbe +{ + private static readonly JsonSerializerOptions _opts = new() + { + PropertyNameCaseInsensitive = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + }; + + /// + 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) = 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(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); + + // 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); + + var host = uri.Host; + var port = uri.IsDefaultPort ? 4840 : uri.Port; + return (host, port); + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverProbe.cs new file mode 100644 index 00000000..c2909f1b --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverProbe.cs @@ -0,0 +1,63 @@ +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.S7; + +/// +/// Cheap TCP-connect probe for the -shaped driver config. +/// Opens a socket to the configured host + ISO-on-TCP port 102 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 S7comm bytes — a richer +/// ISO-on-TCP connection probe is a documented follow-up. +/// +public sealed class S7DriverProbe : IDriverProbe +{ + private static readonly JsonSerializerOptions _opts = new() + { + PropertyNameCaseInsensitive = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + }; + + /// + public string DriverType => "S7"; + + /// + public async Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) + { + S7DriverOptions? 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(); + 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(S7DriverOptions opts) + => (opts.Host, opts.Port); +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverProbe.cs new file mode 100644 index 00000000..e77d4dc2 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverProbe.cs @@ -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; + +/// +/// Cheap TCP-connect probe for the -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. +/// +/// +/// AMS Net ID format is six dot-separated octets (e.g. 192.168.1.10.1.1); 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. +/// +public sealed class TwinCATDriverProbe : IDriverProbe +{ + private static readonly JsonSerializerOptions _opts = new() + { + PropertyNameCaseInsensitive = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + }; + + /// + public string DriverType => "TwinCAT"; + + /// + public async Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) + { + TwinCATDriverOptions? 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(); + 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); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs index fbaf7e4f..845e0232 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs @@ -5,6 +5,17 @@ using ZB.MOM.WW.OtOpcUa.Core.Hosting; namespace ZB.MOM.WW.OtOpcUa.Host.Drivers; +// Probe type aliases — keep the using list concise. +using ModbusProbe = Driver.Modbus.ModbusDriverProbe; +using AbCipProbe = Driver.AbCip.AbCipDriverProbe; +using AbLegacyProbe = Driver.AbLegacy.AbLegacyDriverProbe; +using S7Probe = Driver.S7.S7DriverProbe; +using TwinCATProbe = Driver.TwinCAT.TwinCATDriverProbe; +using FocasProbe = Driver.FOCAS.FocasDriverProbe; +using OpcUaProbe = Driver.OpcUaClient.OpcUaClientDriverProbe; +using GalaxyProbe = Driver.Galaxy.GalaxyDriverProbe; +using HistorianProbe = Driver.Historian.Wonderware.Client.WonderwareHistorianDriverProbe; + /// /// Wires every cross-platform driver assembly's Register(registry, loggerFactory) /// extension into a single singleton and binds the @@ -34,6 +45,18 @@ public static class DriverFactoryBootstrap }); services.AddSingleton(sp => new DriverFactoryRegistryAdapter(sp.GetRequiredService())); + + // One IDriverProbe per driver type — wired into AdminOperationsActor via DI enumeration. + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + return services; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj index bee41156..c0296284 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj @@ -55,7 +55,9 @@ + +